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,440 @@
|
|
|
1
|
+
"""OpenAPI — universal REST adapter from any OpenAPI 3 spec.
|
|
2
|
+
|
|
3
|
+
Inspired by the Vercel AI SDK's "AI Tool Maker" (generate tools from
|
|
4
|
+
OpenAPI specs). Works against any documented REST API: pass a spec
|
|
5
|
+
URL, browse its operations, then call them.
|
|
6
|
+
|
|
7
|
+
This is the universal escape hatch when a service has an OpenAPI/
|
|
8
|
+
Swagger spec but no Cognos-native or Agentic-marketplace tool. Single
|
|
9
|
+
HTTP call to fetch the spec, all operations become callable.
|
|
10
|
+
|
|
11
|
+
Modes:
|
|
12
|
+
- `browse` : list operations in a spec (operation_id, method, path, summary)
|
|
13
|
+
- `call` : execute one operation by operation_id with params
|
|
14
|
+
- `describe`: full input schema + parameter descriptions for one operation
|
|
15
|
+
|
|
16
|
+
Optional auth: `bearer_token` or `headers` parameter; both are
|
|
17
|
+
forwarded with the request. Specs are cached for 10 min so repeated
|
|
18
|
+
calls don't re-fetch.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import urljoin, urlparse
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from core.schemas import ToolResult
|
|
34
|
+
from execution.tools.base import BaseTool
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_DEFAULT_TIMEOUT_S = 30.0
|
|
40
|
+
_SPEC_TTL_S = 600.0 # cache OpenAPI specs for 10 min
|
|
41
|
+
_MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# In-memory spec cache: spec_url → (fetched_at, parsed_spec)
|
|
45
|
+
_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------
|
|
49
|
+
# Spec loading
|
|
50
|
+
# ---------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _load_spec(spec_url: str) -> dict[str, Any]:
|
|
54
|
+
"""Fetch + parse an OpenAPI spec; cached. Supports JSON and YAML."""
|
|
55
|
+
now = time.time()
|
|
56
|
+
cached = _CACHE.get(spec_url)
|
|
57
|
+
if cached and (now - cached[0]) < _SPEC_TTL_S:
|
|
58
|
+
return cached[1]
|
|
59
|
+
|
|
60
|
+
async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT_S) as client:
|
|
61
|
+
resp = await client.get(spec_url, follow_redirects=True)
|
|
62
|
+
resp.raise_for_status()
|
|
63
|
+
text = resp.text
|
|
64
|
+
|
|
65
|
+
# Detect JSON vs YAML by content sniffing first; fall back on extension.
|
|
66
|
+
ctype = resp.headers.get("content-type", "")
|
|
67
|
+
parsed: dict[str, Any]
|
|
68
|
+
text_stripped = text.strip()
|
|
69
|
+
if text_stripped.startswith("{") or "json" in ctype:
|
|
70
|
+
parsed = json.loads(text)
|
|
71
|
+
else:
|
|
72
|
+
try:
|
|
73
|
+
import yaml
|
|
74
|
+
parsed = yaml.safe_load(text)
|
|
75
|
+
except ImportError:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"spec appears to be YAML but PyYAML is not installed; "
|
|
78
|
+
"use a JSON spec or `pip install pyyaml`"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not isinstance(parsed, dict):
|
|
82
|
+
raise ValueError("spec did not parse to a JSON object")
|
|
83
|
+
if not parsed.get("openapi") and not parsed.get("swagger"):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"document does not look like an OpenAPI/Swagger spec "
|
|
86
|
+
"(missing `openapi` / `swagger` key)"
|
|
87
|
+
)
|
|
88
|
+
_CACHE[spec_url] = (now, parsed)
|
|
89
|
+
return parsed
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _server_base_url(spec: dict[str, Any], spec_url: str) -> str:
|
|
93
|
+
"""Resolve the server base URL: spec.servers[0].url, falling back to
|
|
94
|
+
the host of the spec URL itself."""
|
|
95
|
+
servers = spec.get("servers") or []
|
|
96
|
+
if servers and isinstance(servers, list):
|
|
97
|
+
url = (servers[0].get("url") or "").strip()
|
|
98
|
+
if url:
|
|
99
|
+
# If relative (some specs do this), join with spec URL host
|
|
100
|
+
if url.startswith("/") or not urlparse(url).scheme:
|
|
101
|
+
p = urlparse(spec_url)
|
|
102
|
+
return f"{p.scheme}://{p.netloc}{url if url.startswith('/') else '/' + url}"
|
|
103
|
+
return url
|
|
104
|
+
# Swagger 2.0: host + basePath + schemes
|
|
105
|
+
host = spec.get("host")
|
|
106
|
+
base_path = spec.get("basePath", "") or ""
|
|
107
|
+
schemes = spec.get("schemes") or ["https"]
|
|
108
|
+
if host:
|
|
109
|
+
return f"{schemes[0]}://{host}{base_path}"
|
|
110
|
+
# Last resort: use the spec URL's origin
|
|
111
|
+
p = urlparse(spec_url)
|
|
112
|
+
return f"{p.scheme}://{p.netloc}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _iter_operations(spec: dict[str, Any]):
|
|
116
|
+
"""Yield (operation_id, method, path, op_dict) for each operation."""
|
|
117
|
+
paths = spec.get("paths") or {}
|
|
118
|
+
for path, item in paths.items():
|
|
119
|
+
if not isinstance(item, dict):
|
|
120
|
+
continue
|
|
121
|
+
for method in ("get", "post", "put", "patch", "delete", "head", "options"):
|
|
122
|
+
op = item.get(method)
|
|
123
|
+
if not isinstance(op, dict):
|
|
124
|
+
continue
|
|
125
|
+
op_id = op.get("operationId") or _slug(f"{method}_{path}")
|
|
126
|
+
yield op_id, method.upper(), path, op
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _slug(s: str) -> str:
|
|
130
|
+
return re.sub(r"[^a-zA-Z0-9_]+", "_", s).strip("_")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _find_operation(spec: dict[str, Any], operation_id: str
|
|
134
|
+
) -> tuple[str, str, dict[str, Any]] | None:
|
|
135
|
+
for op_id, method, path, op in _iter_operations(spec):
|
|
136
|
+
if op_id == operation_id:
|
|
137
|
+
return method, path, op
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_url_and_body(
|
|
142
|
+
base_url: str, path: str, method: str,
|
|
143
|
+
op: dict[str, Any], params: dict[str, Any],
|
|
144
|
+
) -> tuple[str, dict[str, Any], dict[str, Any], Any]:
|
|
145
|
+
"""Substitute path params, split out query params, locate the body.
|
|
146
|
+
|
|
147
|
+
Returns (url, query, header_overrides, body).
|
|
148
|
+
"""
|
|
149
|
+
parameters = op.get("parameters") or []
|
|
150
|
+
path_params: dict[str, str] = {}
|
|
151
|
+
query: dict[str, Any] = {}
|
|
152
|
+
header_overrides: dict[str, Any] = {}
|
|
153
|
+
|
|
154
|
+
consumed = set()
|
|
155
|
+
for pdef in parameters:
|
|
156
|
+
loc = pdef.get("in")
|
|
157
|
+
name = pdef.get("name")
|
|
158
|
+
if name not in params:
|
|
159
|
+
continue
|
|
160
|
+
if loc == "path":
|
|
161
|
+
path_params[name] = str(params[name])
|
|
162
|
+
consumed.add(name)
|
|
163
|
+
elif loc == "query":
|
|
164
|
+
query[name] = params[name]
|
|
165
|
+
consumed.add(name)
|
|
166
|
+
elif loc == "header":
|
|
167
|
+
header_overrides[name] = str(params[name])
|
|
168
|
+
consumed.add(name)
|
|
169
|
+
# cookie params: not commonly used by LLMs; skip
|
|
170
|
+
|
|
171
|
+
# Path substitution
|
|
172
|
+
rendered_path = path
|
|
173
|
+
for k, v in path_params.items():
|
|
174
|
+
rendered_path = rendered_path.replace("{" + k + "}", v)
|
|
175
|
+
url = base_url.rstrip("/") + "/" + rendered_path.lstrip("/")
|
|
176
|
+
|
|
177
|
+
# Body: anything in params not consumed by parameters[] and the op
|
|
178
|
+
# has a requestBody, treat the rest as the body
|
|
179
|
+
body: Any = None
|
|
180
|
+
if method in ("POST", "PUT", "PATCH") and op.get("requestBody"):
|
|
181
|
+
leftover = {k: v for k, v in params.items() if k not in consumed}
|
|
182
|
+
# If user passed `body` explicitly, use that
|
|
183
|
+
body = params.get("body", leftover or None)
|
|
184
|
+
|
|
185
|
+
return url, query, header_overrides, body
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------
|
|
189
|
+
# Tool
|
|
190
|
+
# ---------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class OpenAPITool(BaseTool):
|
|
194
|
+
name = "OpenAPI"
|
|
195
|
+
description = (
|
|
196
|
+
"Universal REST adapter for any service with an OpenAPI 3 (or "
|
|
197
|
+
"Swagger 2) spec. Three modes: 'browse' lists all operations "
|
|
198
|
+
"in a spec; 'describe' shows one operation's input schema; "
|
|
199
|
+
"'call' executes an operation by operation_id with params. "
|
|
200
|
+
"Use this when the service has a documented spec but no "
|
|
201
|
+
"Cognos-native or marketplace tool. Pass `bearer_token` or "
|
|
202
|
+
"`headers` for auth."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def input_schema(self) -> dict[str, Any]:
|
|
207
|
+
return {
|
|
208
|
+
"type": "object",
|
|
209
|
+
"properties": {
|
|
210
|
+
"action": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"enum": ["browse", "describe", "call"],
|
|
213
|
+
"description": "What to do: list ops / show one op's schema / execute one op.",
|
|
214
|
+
},
|
|
215
|
+
"spec_url": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"description": "URL to the OpenAPI/Swagger spec (JSON or YAML).",
|
|
218
|
+
},
|
|
219
|
+
"operation_id": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"description": "For describe/call: the operation_id from the spec.",
|
|
222
|
+
},
|
|
223
|
+
"params": {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"description": "For call: parameter values keyed by parameter name. Path/query/header params are routed automatically; anything left over for POST/PUT/PATCH becomes the JSON body (or pass `body` explicitly).",
|
|
226
|
+
},
|
|
227
|
+
"bearer_token": {
|
|
228
|
+
"type": "string",
|
|
229
|
+
"description": "Optional: forwarded as `Authorization: Bearer <token>`.",
|
|
230
|
+
},
|
|
231
|
+
"headers": {
|
|
232
|
+
"type": "object",
|
|
233
|
+
"description": "Optional extra request headers (override anything generated automatically).",
|
|
234
|
+
},
|
|
235
|
+
"timeout_s": {
|
|
236
|
+
"type": "number",
|
|
237
|
+
"description": "Override timeout (default 30s, max 120s).",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
"required": ["action", "spec_url"],
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
244
|
+
action = (kwargs.get("action") or "").lower().strip()
|
|
245
|
+
spec_url = (kwargs.get("spec_url") or "").strip()
|
|
246
|
+
if not spec_url:
|
|
247
|
+
return ToolResult(
|
|
248
|
+
tool_name=self.name, status="error",
|
|
249
|
+
error="`spec_url` is required",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
spec = await _load_spec(spec_url)
|
|
254
|
+
except (httpx.HTTPError, ValueError, RuntimeError) as e:
|
|
255
|
+
return ToolResult(
|
|
256
|
+
tool_name=self.name, status="error",
|
|
257
|
+
error=f"failed to load spec: {e}",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if action == "browse":
|
|
261
|
+
return self._browse(spec, spec_url)
|
|
262
|
+
if action == "describe":
|
|
263
|
+
return self._describe(spec, kwargs)
|
|
264
|
+
if action == "call":
|
|
265
|
+
return await self._call(spec, spec_url, kwargs)
|
|
266
|
+
return ToolResult(
|
|
267
|
+
tool_name=self.name, status="error",
|
|
268
|
+
error=f"unknown action {action!r}; expected browse/describe/call",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# -- modes -----------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def _browse(self, spec: dict[str, Any], spec_url: str) -> ToolResult:
|
|
274
|
+
info = spec.get("info") or {}
|
|
275
|
+
title = info.get("title") or "(untitled)"
|
|
276
|
+
version = info.get("version") or "(no version)"
|
|
277
|
+
base = _server_base_url(spec, spec_url)
|
|
278
|
+
|
|
279
|
+
ops: list[dict[str, Any]] = []
|
|
280
|
+
for op_id, method, path, op in _iter_operations(spec):
|
|
281
|
+
ops.append({
|
|
282
|
+
"operation_id": op_id,
|
|
283
|
+
"method": method,
|
|
284
|
+
"path": path,
|
|
285
|
+
"summary": (op.get("summary") or "").strip()[:160],
|
|
286
|
+
"tags": op.get("tags") or [],
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if not ops:
|
|
290
|
+
return ToolResult(
|
|
291
|
+
tool_name=self.name, status="success",
|
|
292
|
+
output=f"{title} ({version}): spec has no operations",
|
|
293
|
+
metadata={"title": title, "operations": []},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
lines = [f"{title} ({version}) — {len(ops)} operation(s)",
|
|
297
|
+
f" base url: {base}", ""]
|
|
298
|
+
for op in ops:
|
|
299
|
+
tags = f" [{','.join(op['tags'])}]" if op["tags"] else ""
|
|
300
|
+
line = f" {op['method']:6s} {op['path']:50s} → {op['operation_id']}{tags}"
|
|
301
|
+
lines.append(line)
|
|
302
|
+
if op["summary"]:
|
|
303
|
+
lines.append(f" {op['summary']}")
|
|
304
|
+
|
|
305
|
+
return ToolResult(
|
|
306
|
+
tool_name=self.name, status="success",
|
|
307
|
+
output="\n".join(lines),
|
|
308
|
+
metadata={"title": title, "base_url": base, "operations": ops},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _describe(self, spec: dict[str, Any], kwargs: dict) -> ToolResult:
|
|
312
|
+
op_id = (kwargs.get("operation_id") or "").strip()
|
|
313
|
+
if not op_id:
|
|
314
|
+
return ToolResult(
|
|
315
|
+
tool_name=self.name, status="error",
|
|
316
|
+
error="`operation_id` is required for describe",
|
|
317
|
+
)
|
|
318
|
+
found = _find_operation(spec, op_id)
|
|
319
|
+
if not found:
|
|
320
|
+
return ToolResult(
|
|
321
|
+
tool_name=self.name, status="error",
|
|
322
|
+
error=f"operation {op_id!r} not in spec",
|
|
323
|
+
)
|
|
324
|
+
method, path, op = found
|
|
325
|
+
|
|
326
|
+
lines = [
|
|
327
|
+
f"{method} {path} → operation_id={op_id}",
|
|
328
|
+
f" summary: {op.get('summary', '').strip()}",
|
|
329
|
+
f" description: {op.get('description', '').strip()[:300]}",
|
|
330
|
+
f" tags: {op.get('tags') or []}",
|
|
331
|
+
]
|
|
332
|
+
params = op.get("parameters") or []
|
|
333
|
+
if params:
|
|
334
|
+
lines.append(" parameters:")
|
|
335
|
+
for pdef in params:
|
|
336
|
+
req = " (required)" if pdef.get("required") else ""
|
|
337
|
+
lines.append(
|
|
338
|
+
f" - {pdef.get('name')} (in={pdef.get('in')}, "
|
|
339
|
+
f"type={(pdef.get('schema') or {}).get('type', '?')}){req}: "
|
|
340
|
+
f"{(pdef.get('description') or '')[:120]}"
|
|
341
|
+
)
|
|
342
|
+
rb = op.get("requestBody")
|
|
343
|
+
if rb:
|
|
344
|
+
content = (rb.get("content") or {})
|
|
345
|
+
mime = next(iter(content.keys()), "?")
|
|
346
|
+
schema = (content.get(mime) or {}).get("schema") or {}
|
|
347
|
+
lines.append(f" requestBody ({mime}):")
|
|
348
|
+
lines.append(f" schema: {json.dumps(schema, indent=2)[:600]}")
|
|
349
|
+
return ToolResult(
|
|
350
|
+
tool_name=self.name, status="success",
|
|
351
|
+
output="\n".join(lines),
|
|
352
|
+
metadata={"operation_id": op_id, "method": method, "path": path,
|
|
353
|
+
"operation": op},
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
async def _call(self, spec: dict[str, Any], spec_url: str,
|
|
357
|
+
kwargs: dict) -> ToolResult:
|
|
358
|
+
op_id = (kwargs.get("operation_id") or "").strip()
|
|
359
|
+
params = kwargs.get("params") or {}
|
|
360
|
+
bearer = (kwargs.get("bearer_token") or "").strip()
|
|
361
|
+
extra_headers = kwargs.get("headers") or {}
|
|
362
|
+
timeout = max(1.0, min(120.0, float(kwargs.get("timeout_s") or _DEFAULT_TIMEOUT_S)))
|
|
363
|
+
|
|
364
|
+
if not op_id:
|
|
365
|
+
return ToolResult(
|
|
366
|
+
tool_name=self.name, status="error",
|
|
367
|
+
error="`operation_id` is required for call",
|
|
368
|
+
)
|
|
369
|
+
found = _find_operation(spec, op_id)
|
|
370
|
+
if not found:
|
|
371
|
+
return ToolResult(
|
|
372
|
+
tool_name=self.name, status="error",
|
|
373
|
+
error=f"operation {op_id!r} not in spec",
|
|
374
|
+
)
|
|
375
|
+
method, path, op = found
|
|
376
|
+
base = _server_base_url(spec, spec_url)
|
|
377
|
+
url, query, header_overrides, body = _build_url_and_body(
|
|
378
|
+
base, path, method, op, params
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Merge headers: bearer < spec-derived < user-supplied
|
|
382
|
+
headers: dict[str, Any] = {}
|
|
383
|
+
if bearer:
|
|
384
|
+
headers["Authorization"] = f"Bearer {bearer}"
|
|
385
|
+
headers.update(header_overrides)
|
|
386
|
+
headers.update({str(k): str(v) for k, v in extra_headers.items()})
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
async with httpx.AsyncClient(
|
|
390
|
+
timeout=timeout, follow_redirects=True
|
|
391
|
+
) as client:
|
|
392
|
+
resp = await client.request(
|
|
393
|
+
method, url,
|
|
394
|
+
params=query if query else None,
|
|
395
|
+
headers=headers if headers else None,
|
|
396
|
+
json=body if body is not None else None,
|
|
397
|
+
)
|
|
398
|
+
except httpx.TimeoutException:
|
|
399
|
+
return ToolResult(
|
|
400
|
+
tool_name=self.name, status="error",
|
|
401
|
+
error=f"timed out after {timeout}s calling {op_id}",
|
|
402
|
+
)
|
|
403
|
+
except httpx.RequestError as e:
|
|
404
|
+
return ToolResult(
|
|
405
|
+
tool_name=self.name, status="error",
|
|
406
|
+
error=f"network error: {e}",
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
body_bytes = resp.content[:_MAX_BODY_BYTES]
|
|
410
|
+
ctype = resp.headers.get("content-type", "")
|
|
411
|
+
if "json" in ctype:
|
|
412
|
+
try:
|
|
413
|
+
resp_body = resp.json()
|
|
414
|
+
body_str = json.dumps(resp_body, indent=2)[:4000]
|
|
415
|
+
except Exception:
|
|
416
|
+
body_str = body_bytes.decode("utf-8", errors="replace")[:4000]
|
|
417
|
+
resp_body = body_str
|
|
418
|
+
else:
|
|
419
|
+
body_str = body_bytes.decode("utf-8", errors="replace")[:4000]
|
|
420
|
+
resp_body = body_str
|
|
421
|
+
|
|
422
|
+
out = (
|
|
423
|
+
f"{method} {url}\n"
|
|
424
|
+
f" status: {resp.status_code} {resp.reason_phrase}\n"
|
|
425
|
+
f" type: {ctype}\n"
|
|
426
|
+
f"\n--- body ---\n{body_str}"
|
|
427
|
+
)
|
|
428
|
+
return ToolResult(
|
|
429
|
+
tool_name=self.name,
|
|
430
|
+
status="success" if resp.status_code < 400 else "error",
|
|
431
|
+
output=out,
|
|
432
|
+
error=None if resp.status_code < 400 else f"HTTP {resp.status_code}",
|
|
433
|
+
metadata={
|
|
434
|
+
"operation_id": op_id,
|
|
435
|
+
"method": method,
|
|
436
|
+
"url": str(resp.url),
|
|
437
|
+
"status_code": resp.status_code,
|
|
438
|
+
"body": resp_body,
|
|
439
|
+
},
|
|
440
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Plan Mode tools — toggle the read-only execution mode.
|
|
2
|
+
|
|
3
|
+
Two tools:
|
|
4
|
+
|
|
5
|
+
- **`EnterPlanMode`** — switch ON. Mutating tools (Bash, Write,
|
|
6
|
+
Edit, NotebookEdit, PythonExec, HttpRequest, Sandbox, etc.)
|
|
7
|
+
will refuse to run; read-only tools (Read, Grep, Glob,
|
|
8
|
+
SystemInfo, Calculator, …) keep working. Use when the user
|
|
9
|
+
wants the agent to *plan* a change and present it before
|
|
10
|
+
actually applying anything.
|
|
11
|
+
- **`ExitPlanMode`** — switch OFF. Mutating tools are allowed
|
|
12
|
+
again. The agent should call this once the user has reviewed
|
|
13
|
+
and approved the plan.
|
|
14
|
+
|
|
15
|
+
These are status-flip tools, not blocking prompts — the agent can
|
|
16
|
+
toggle plan mode mid-conversation and the executor's dispatch
|
|
17
|
+
refuses mutators until the next ExitPlanMode.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from core.plan_mode import enter, exit_mode, is_active
|
|
25
|
+
from core.schemas import ToolResult
|
|
26
|
+
from execution.tools.base import BaseTool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EnterPlanModeTool(BaseTool):
|
|
30
|
+
name = "EnterPlanMode"
|
|
31
|
+
description = (
|
|
32
|
+
"Enter plan mode — the executor will refuse any side-effect "
|
|
33
|
+
"tool (Bash, Write, Edit, NotebookEdit, PythonExec, "
|
|
34
|
+
"HttpRequest, Sandbox, UpdateMemory, Cron*, Task*, Worktree*, "
|
|
35
|
+
"Draw, EditImage, Storyboard, Speak, Artifact) until "
|
|
36
|
+
"ExitPlanMode is called. Read-only tools (Read, Grep, Glob, "
|
|
37
|
+
"FindAnywhere, SystemInfo, Calculator, DateTime, "
|
|
38
|
+
"SemanticSearch, DescribeImage, TranscribeAudio, WebSearch, "
|
|
39
|
+
"WebFetch, CognosCard, Think, AskUserQuestion, "
|
|
40
|
+
"MCPListServers, TaskList/Get/Output, CronList) keep "
|
|
41
|
+
"working. Use this when the user wants you to plan first "
|
|
42
|
+
"and apply changes only after review."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def input_schema(self) -> dict[str, Any]:
|
|
47
|
+
return {"type": "object", "properties": {}}
|
|
48
|
+
|
|
49
|
+
async def execute(self, **_: Any) -> ToolResult:
|
|
50
|
+
if is_active():
|
|
51
|
+
return ToolResult(
|
|
52
|
+
tool_name=self.name, status="success",
|
|
53
|
+
output="Plan mode is already ON.",
|
|
54
|
+
metadata={"plan_mode": True, "changed": False},
|
|
55
|
+
)
|
|
56
|
+
enter()
|
|
57
|
+
return ToolResult(
|
|
58
|
+
tool_name=self.name, status="success",
|
|
59
|
+
output=(
|
|
60
|
+
"Plan mode is now ON. Mutating tools are blocked; "
|
|
61
|
+
"read-only tools still work. Use ExitPlanMode to "
|
|
62
|
+
"return to normal operation."
|
|
63
|
+
),
|
|
64
|
+
metadata={"plan_mode": True, "changed": True},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ExitPlanModeTool(BaseTool):
|
|
69
|
+
name = "ExitPlanMode"
|
|
70
|
+
description = (
|
|
71
|
+
"Exit plan mode — re-enable mutating tools. Call this once "
|
|
72
|
+
"the user has reviewed the planned changes and wants to "
|
|
73
|
+
"actually apply them."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def input_schema(self) -> dict[str, Any]:
|
|
78
|
+
return {"type": "object", "properties": {}}
|
|
79
|
+
|
|
80
|
+
async def execute(self, **_: Any) -> ToolResult:
|
|
81
|
+
if not is_active():
|
|
82
|
+
return ToolResult(
|
|
83
|
+
tool_name=self.name, status="success",
|
|
84
|
+
output="Plan mode is already OFF.",
|
|
85
|
+
metadata={"plan_mode": False, "changed": False},
|
|
86
|
+
)
|
|
87
|
+
exit_mode()
|
|
88
|
+
return ToolResult(
|
|
89
|
+
tool_name=self.name, status="success",
|
|
90
|
+
output=(
|
|
91
|
+
"Plan mode is now OFF. Mutating tools are allowed "
|
|
92
|
+
"again."
|
|
93
|
+
),
|
|
94
|
+
metadata={"plan_mode": False, "changed": True},
|
|
95
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""PushNotification — desktop notification when a long job completes.
|
|
2
|
+
|
|
3
|
+
Sends an OS-level desktop notification so the user sees it even if
|
|
4
|
+
they've alt-tabbed away from the browser/terminal. Tries `notify-send`
|
|
5
|
+
on Linux first (which works under the user's XFCE/GNOME/KDE setup),
|
|
6
|
+
falls back to a terminal bell (BEL char) for headless sessions, and
|
|
7
|
+
returns a clear status either way.
|
|
8
|
+
|
|
9
|
+
Use cases:
|
|
10
|
+
- Storyboard finished generating (15-40 min jobs)
|
|
11
|
+
- Phase-N retrain completed
|
|
12
|
+
- FLUX-Kontext panel rendered
|
|
13
|
+
- Anything that takes long enough that the user's attention has
|
|
14
|
+
drifted elsewhere
|
|
15
|
+
|
|
16
|
+
Not for chat-turn updates — those go in the response text directly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from core.schemas import ToolResult
|
|
27
|
+
from execution.tools.base import BaseTool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Urgency mapping to notify-send levels. Same vocabulary FreeDesktop
|
|
31
|
+
# uses, exposed as plain string for clarity.
|
|
32
|
+
_URGENCY_MAP = {
|
|
33
|
+
"low": "low",
|
|
34
|
+
"normal": "normal",
|
|
35
|
+
"high": "critical", # notify-send calls it "critical"
|
|
36
|
+
"critical": "critical",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PushNotificationTool(BaseTool):
|
|
41
|
+
mutates = True
|
|
42
|
+
name = "PushNotification"
|
|
43
|
+
description = (
|
|
44
|
+
"Send a desktop notification to the user. Useful when a "
|
|
45
|
+
"long-running job (storyboard, FLUX edit, retrain) finishes "
|
|
46
|
+
"and the user has switched away from the chat. Provide a "
|
|
47
|
+
"short `title` and optional `message` + `urgency` "
|
|
48
|
+
"(`low` / `normal` / `high`). Uses `notify-send` on Linux; "
|
|
49
|
+
"falls back to a terminal bell if no notification daemon is "
|
|
50
|
+
"available. Don't spam — one notification per completed job."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def input_schema(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"title": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "Notification headline. Keep under 60 chars.",
|
|
61
|
+
},
|
|
62
|
+
"message": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"description": (
|
|
65
|
+
"Body text. Keep under 200 chars. Optional; "
|
|
66
|
+
"if omitted only the title appears."
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
"urgency": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"enum": list(_URGENCY_MAP.keys()),
|
|
72
|
+
"description": "low / normal / high (default normal).",
|
|
73
|
+
"default": "normal",
|
|
74
|
+
},
|
|
75
|
+
"timeout_ms": {
|
|
76
|
+
"type": "integer",
|
|
77
|
+
"description": (
|
|
78
|
+
"Auto-dismiss after N ms. Default 5000. Set "
|
|
79
|
+
"0 for sticky."
|
|
80
|
+
),
|
|
81
|
+
"default": 5000,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
"required": ["title"],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
88
|
+
title = (kwargs.get("title") or "").strip()
|
|
89
|
+
if not title:
|
|
90
|
+
return ToolResult(
|
|
91
|
+
tool_name=self.name, status="error",
|
|
92
|
+
error="`title` is required",
|
|
93
|
+
)
|
|
94
|
+
message = (kwargs.get("message") or "").strip()
|
|
95
|
+
urgency = (kwargs.get("urgency") or "normal").lower()
|
|
96
|
+
urgency = _URGENCY_MAP.get(urgency, "normal")
|
|
97
|
+
try:
|
|
98
|
+
timeout_ms = int(kwargs.get("timeout_ms", 5000))
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
timeout_ms = 5000
|
|
101
|
+
|
|
102
|
+
# Channel 1: notify-send (Linux desktop)
|
|
103
|
+
notify = shutil.which("notify-send")
|
|
104
|
+
if notify is not None:
|
|
105
|
+
argv = [
|
|
106
|
+
notify,
|
|
107
|
+
"--app-name", "Cognos",
|
|
108
|
+
"--urgency", urgency,
|
|
109
|
+
"--expire-time", str(max(0, timeout_ms)),
|
|
110
|
+
title,
|
|
111
|
+
]
|
|
112
|
+
if message:
|
|
113
|
+
argv.append(message)
|
|
114
|
+
try:
|
|
115
|
+
proc = await asyncio.create_subprocess_exec(
|
|
116
|
+
*argv,
|
|
117
|
+
stdout=asyncio.subprocess.PIPE,
|
|
118
|
+
stderr=asyncio.subprocess.PIPE,
|
|
119
|
+
)
|
|
120
|
+
_, stderr = await asyncio.wait_for(
|
|
121
|
+
proc.communicate(), timeout=5,
|
|
122
|
+
)
|
|
123
|
+
if proc.returncode == 0:
|
|
124
|
+
return ToolResult(
|
|
125
|
+
tool_name=self.name, status="success",
|
|
126
|
+
output=f"Notification sent via notify-send: {title!r}",
|
|
127
|
+
metadata={
|
|
128
|
+
"channel": "notify-send",
|
|
129
|
+
"title": title,
|
|
130
|
+
"urgency": urgency,
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
# notify-send returned non-zero — fall through to bell
|
|
134
|
+
except (asyncio.TimeoutError, OSError):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# Channel 2: terminal bell (works in any TTY)
|
|
138
|
+
try:
|
|
139
|
+
sys.stderr.write("\a")
|
|
140
|
+
sys.stderr.flush()
|
|
141
|
+
return ToolResult(
|
|
142
|
+
tool_name=self.name, status="success",
|
|
143
|
+
output=(
|
|
144
|
+
f"Notification sent via terminal bell (no desktop "
|
|
145
|
+
f"notifier available): {title!r}"
|
|
146
|
+
),
|
|
147
|
+
metadata={
|
|
148
|
+
"channel": "bell",
|
|
149
|
+
"title": title,
|
|
150
|
+
"fallback_reason": "notify-send unavailable",
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return ToolResult(
|
|
155
|
+
tool_name=self.name, status="error",
|
|
156
|
+
error=f"could not deliver notification: {e}",
|
|
157
|
+
)
|