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,384 @@
|
|
|
1
|
+
"""LongCat Avatar tool — animate a portrait with speech audio.
|
|
2
|
+
|
|
3
|
+
The tool drives a local ComfyUI server running the WAN-S2V (Speech-to-Video)
|
|
4
|
+
inference graph at ``cognos_mcp/workflows/wan_s2v_longcat_avatar.json``.
|
|
5
|
+
The underlying model is ``LongCat-Avatar-Single`` (WAN 2.2 S2V fine-tune
|
|
6
|
+
by vantagewithai), loaded from a Q5_1 GGUF.
|
|
7
|
+
|
|
8
|
+
Flow:
|
|
9
|
+
1. Resolve ref_image + audio (FileStore id or absolute path)
|
|
10
|
+
2. Stage them into ComfyUI's input/ dir
|
|
11
|
+
3. Substitute placeholders + tunables in the workflow JSON
|
|
12
|
+
4. POST to ComfyUI /prompt, poll /history/<id> until done
|
|
13
|
+
5. Pull the output video, persist via FileStore, return a markdown link
|
|
14
|
+
|
|
15
|
+
ComfyUI must be running. The tool does NOT auto-start it — if /system_stats
|
|
16
|
+
is unreachable, it returns an actionable error pointing at the start command.
|
|
17
|
+
Subprocess auto-start can be layered on later (see CONTEXT.md /
|
|
18
|
+
"Caudate" → tool gap log).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from core.schemas import ToolResult
|
|
37
|
+
from execution.tools.base import BaseTool
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
COMFYUI_ROOT = Path("/home/raveuk/comfy/ComfyUI")
|
|
43
|
+
WORKFLOW_PATH = (
|
|
44
|
+
Path(__file__).resolve().parent.parent.parent
|
|
45
|
+
/ "cognos_mcp" / "workflows" / "wan_s2v_longcat_avatar.json"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _public_base_url() -> str:
|
|
50
|
+
return os.environ.get("COGNOS_PUBLIC_URL", "http://127.0.0.1:8000").rstrip("/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LongCatAvatarTool(BaseTool):
|
|
54
|
+
mutates = True
|
|
55
|
+
name = "LongCatAvatar"
|
|
56
|
+
description = (
|
|
57
|
+
"Animate a portrait image with a speech audio clip to produce a "
|
|
58
|
+
"talking-head video. Provide a `ref_image` (FileStore id from a "
|
|
59
|
+
"prior [file:...] marker, or an absolute path to a portrait), an "
|
|
60
|
+
"`audio` clip (wav/mp3/flac of the speech to lip-sync), and a "
|
|
61
|
+
"`prompt` describing the avatar's appearance / setting. Returns "
|
|
62
|
+
"a `markdown` field — paste it VERBATIM into your reply so the "
|
|
63
|
+
"UI renders the video inline. Generation takes ~30-90 seconds "
|
|
64
|
+
"on a 3090 at default settings. Requires a local ComfyUI server "
|
|
65
|
+
"running on 127.0.0.1:8188 with the LongCat-Avatar GGUF + WAN "
|
|
66
|
+
"companions installed (see project memory `longcat-to-integrate`)."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
file_store: Any | None = None,
|
|
72
|
+
comfyui_url: str = "http://127.0.0.1:8188",
|
|
73
|
+
timeout_seconds: float = 600.0,
|
|
74
|
+
):
|
|
75
|
+
self._file_store = file_store
|
|
76
|
+
self._url = comfyui_url.rstrip("/")
|
|
77
|
+
self._timeout = timeout_seconds
|
|
78
|
+
# When set, the tool can synthesize speech from a `text` arg
|
|
79
|
+
# instead of requiring a pre-recorded `audio` file. Wired by
|
|
80
|
+
# CognosAgent at startup (agent.py). Keeping it injected
|
|
81
|
+
# rather than imported avoids a hard dep on the Speak tool here.
|
|
82
|
+
self._speak_tool: Any | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def input_schema(self) -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {
|
|
89
|
+
"ref_image": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"description": "Avatar portrait. Either a FileStore id (uuid from a [file:...] marker) or an absolute path.",
|
|
92
|
+
},
|
|
93
|
+
"audio": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Speech clip to lip-sync. FileStore id or absolute path. WAV/MP3/FLAC. Either `audio` OR `text` must be provided.",
|
|
96
|
+
},
|
|
97
|
+
"text": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "Text to synthesize into speech (Kokoro TTS) and use as the audio driver. Use this instead of `audio` when you want the avatar to say something the user typed. Either `audio` OR `text` must be provided; if both, `audio` wins.",
|
|
100
|
+
},
|
|
101
|
+
"voice": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"description": "TTS voice id (Kokoro). Only honoured when `text` is supplied. Optional.",
|
|
104
|
+
},
|
|
105
|
+
"prompt": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"description": "Positive prompt describing the avatar and scene (e.g. 'a young woman speaking expressively, soft studio lighting').",
|
|
108
|
+
},
|
|
109
|
+
"negative_prompt": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"description": "Negative prompt. Default: 'blurry, distorted, low quality, watermark, deformed face'.",
|
|
112
|
+
},
|
|
113
|
+
"width": {"type": "integer", "description": "Output width, multiple of 16. Default 832 (horizontal). Use 480 for vertical/portrait."},
|
|
114
|
+
"height": {"type": "integer", "description": "Output height, multiple of 16. Default 480."},
|
|
115
|
+
"length": {"type": "integer", "description": "Frame count. MUST satisfy (length-1) %% 4 == 0. Default 77 (~5s at 16fps); 121 ≈ 7.5s; 161 ≈ 10s."},
|
|
116
|
+
"seed": {"type": "integer", "description": "Random seed. Default 42; randomize for variety."},
|
|
117
|
+
"steps": {"type": "integer", "description": "Sampler steps. 20-30 typical. Default 20."},
|
|
118
|
+
"cfg": {"type": "number", "description": "Classifier-free guidance scale. Default 6.0."},
|
|
119
|
+
"sampler": {"type": "string", "description": "KSampler sampler_name. Default 'euler'. Try 'dpmpp_2m' or 'uni_pc' for sharper."},
|
|
120
|
+
"scheduler": {"type": "string", "description": "Sampler scheduler. Default 'simple'. Try 'beta' for cleaner motion."},
|
|
121
|
+
"fps": {"type": "number", "description": "Output framerate. Default 16 (matches WAN-S2V training)."},
|
|
122
|
+
},
|
|
123
|
+
"required": ["ref_image", "prompt"],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def set_file_store(self, file_store: Any) -> None:
|
|
127
|
+
self._file_store = file_store
|
|
128
|
+
|
|
129
|
+
def set_speak_tool(self, speak_tool: Any) -> None:
|
|
130
|
+
"""Inject a SpeakTool reference so the `text` parameter can
|
|
131
|
+
drive Kokoro TTS in-process. Optional — without it, callers
|
|
132
|
+
must pass `audio` (FileStore id or path)."""
|
|
133
|
+
self._speak_tool = speak_tool
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def _resolve_input(self, ref: str, *, expected_kind: str | None = None) -> Path:
|
|
138
|
+
"""Resolve a FileStore id or filesystem path to an absolute Path.
|
|
139
|
+
|
|
140
|
+
Raises FileNotFoundError on bad ids / missing files.
|
|
141
|
+
"""
|
|
142
|
+
ref = (ref or "").strip()
|
|
143
|
+
if not ref:
|
|
144
|
+
raise ValueError("empty file reference")
|
|
145
|
+
# FileStore ids are UUIDs (no '/' or '.' typical for paths)
|
|
146
|
+
if "/" not in ref and "." not in ref and self._file_store is not None:
|
|
147
|
+
rec = self._file_store.get(ref)
|
|
148
|
+
if rec is not None:
|
|
149
|
+
return Path(rec.path)
|
|
150
|
+
# Otherwise treat as a filesystem path
|
|
151
|
+
p = Path(ref).expanduser().resolve()
|
|
152
|
+
if not p.exists():
|
|
153
|
+
raise FileNotFoundError(f"Not a FileStore id and not a file on disk: {ref}")
|
|
154
|
+
return p
|
|
155
|
+
|
|
156
|
+
def _build_workflow(self, kwargs: dict[str, Any], ref_image_name: str, audio_name: str) -> dict[str, Any]:
|
|
157
|
+
"""Load the template workflow + substitute placeholders + apply overrides."""
|
|
158
|
+
wf = json.loads(WORKFLOW_PATH.read_text())
|
|
159
|
+
|
|
160
|
+
# Placeholder substitutions (the 4 __*__ tokens from the template)
|
|
161
|
+
wf["5"]["inputs"]["image"] = ref_image_name
|
|
162
|
+
wf["6"]["inputs"]["audio"] = audio_name
|
|
163
|
+
wf["7"]["inputs"]["text"] = kwargs["prompt"]
|
|
164
|
+
wf["8"]["inputs"]["text"] = (
|
|
165
|
+
kwargs.get("negative_prompt")
|
|
166
|
+
or "blurry, distorted, low quality, watermark, deformed face"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Tunable overrides — only apply when the caller passed a value.
|
|
170
|
+
def _maybe(node_id: str, field: str, key: str, cast):
|
|
171
|
+
if kwargs.get(key) is not None:
|
|
172
|
+
wf[node_id]["inputs"][field] = cast(kwargs[key])
|
|
173
|
+
|
|
174
|
+
_maybe("10", "width", "width", int)
|
|
175
|
+
_maybe("10", "height", "height", int)
|
|
176
|
+
_maybe("10", "length", "length", int)
|
|
177
|
+
_maybe("11", "seed", "seed", int)
|
|
178
|
+
_maybe("11", "steps", "steps", int)
|
|
179
|
+
_maybe("11", "cfg", "cfg", float)
|
|
180
|
+
_maybe("11", "sampler_name","sampler", str)
|
|
181
|
+
_maybe("11", "scheduler", "scheduler", str)
|
|
182
|
+
_maybe("13", "fps", "fps", float)
|
|
183
|
+
|
|
184
|
+
return wf
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
async def _ensure_comfyui_up(self, client: httpx.AsyncClient) -> str | None:
|
|
189
|
+
"""Probe ComfyUI's /system_stats. Returns None if up, or an error string."""
|
|
190
|
+
try:
|
|
191
|
+
r = await client.get(f"{self._url}/system_stats", timeout=5.0)
|
|
192
|
+
r.raise_for_status()
|
|
193
|
+
return None
|
|
194
|
+
except Exception as e:
|
|
195
|
+
return (
|
|
196
|
+
f"ComfyUI not reachable at {self._url} ({type(e).__name__}: {e}). "
|
|
197
|
+
f"Start it with:\n"
|
|
198
|
+
f" cd {COMFYUI_ROOT} && python3 main.py --listen 127.0.0.1 --port 8188\n"
|
|
199
|
+
f"Then re-run this tool."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def _submit_and_wait(self, client: httpx.AsyncClient, workflow: dict) -> tuple[str | None, Path | None, str | None]:
|
|
203
|
+
"""POST the workflow and poll /history until done. Returns (prompt_id, output_path, error_str)."""
|
|
204
|
+
client_id = uuid.uuid4().hex
|
|
205
|
+
try:
|
|
206
|
+
r = await client.post(
|
|
207
|
+
f"{self._url}/prompt",
|
|
208
|
+
json={"prompt": workflow, "client_id": client_id},
|
|
209
|
+
timeout=30.0,
|
|
210
|
+
)
|
|
211
|
+
r.raise_for_status()
|
|
212
|
+
data = r.json()
|
|
213
|
+
except httpx.HTTPStatusError as e:
|
|
214
|
+
# ComfyUI returns 400 with a JSON error body when the workflow is invalid
|
|
215
|
+
body = e.response.text if e.response is not None else "<no body>"
|
|
216
|
+
return None, None, f"ComfyUI rejected the prompt ({e.response.status_code}): {body[:500]}"
|
|
217
|
+
except Exception as e:
|
|
218
|
+
return None, None, f"ComfyUI /prompt POST failed: {type(e).__name__}: {e}"
|
|
219
|
+
|
|
220
|
+
prompt_id = data.get("prompt_id")
|
|
221
|
+
if not prompt_id:
|
|
222
|
+
return None, None, f"ComfyUI did not return a prompt_id: {data}"
|
|
223
|
+
|
|
224
|
+
# Poll /history/<id>
|
|
225
|
+
deadline = time.time() + self._timeout
|
|
226
|
+
last_status = None
|
|
227
|
+
while time.time() < deadline:
|
|
228
|
+
await asyncio.sleep(2.0)
|
|
229
|
+
try:
|
|
230
|
+
h = await client.get(f"{self._url}/history/{prompt_id}", timeout=10.0)
|
|
231
|
+
h.raise_for_status()
|
|
232
|
+
hist = h.json().get(prompt_id)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.debug(f"history poll failed (will retry): {e}")
|
|
235
|
+
continue
|
|
236
|
+
if not hist:
|
|
237
|
+
continue
|
|
238
|
+
status = hist.get("status", {})
|
|
239
|
+
last_status = status
|
|
240
|
+
status_str = status.get("status_str")
|
|
241
|
+
if status_str == "error":
|
|
242
|
+
msgs = status.get("messages", [])
|
|
243
|
+
return prompt_id, None, f"ComfyUI execution error: {msgs}"
|
|
244
|
+
if not status.get("completed"):
|
|
245
|
+
continue
|
|
246
|
+
# Done — find the SaveVideo output (node 14)
|
|
247
|
+
outputs = hist.get("outputs", {}) or {}
|
|
248
|
+
node_out = outputs.get("14", {}) or {}
|
|
249
|
+
for key in ("videos", "images", "gifs"):
|
|
250
|
+
items = node_out.get(key) or []
|
|
251
|
+
if items:
|
|
252
|
+
item = items[0]
|
|
253
|
+
out_path = (
|
|
254
|
+
COMFYUI_ROOT / "output"
|
|
255
|
+
/ (item.get("subfolder") or "")
|
|
256
|
+
/ item["filename"]
|
|
257
|
+
)
|
|
258
|
+
return prompt_id, out_path, None
|
|
259
|
+
return prompt_id, None, f"ComfyUI completed but no video output found (node 14 outputs: {node_out})"
|
|
260
|
+
|
|
261
|
+
return prompt_id, None, f"timed out after {self._timeout}s (last status: {last_status})"
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
266
|
+
prompt = (kwargs.get("prompt") or "").strip()
|
|
267
|
+
if not prompt:
|
|
268
|
+
return self._error("No prompt provided")
|
|
269
|
+
if self._file_store is None:
|
|
270
|
+
return self._error("LongCatAvatar is not wired to a FileStore. Configure one on the tool.")
|
|
271
|
+
|
|
272
|
+
# 1. Resolve inputs. `audio` (FileStore id or path) wins over
|
|
273
|
+
# `text` if both supplied; if neither, error out.
|
|
274
|
+
audio_ref = (kwargs.get("audio") or "").strip()
|
|
275
|
+
text_to_speak = (kwargs.get("text") or "").strip()
|
|
276
|
+
try:
|
|
277
|
+
ref_image_src = self._resolve_input(kwargs["ref_image"])
|
|
278
|
+
except (FileNotFoundError, ValueError) as e:
|
|
279
|
+
return self._error(str(e))
|
|
280
|
+
|
|
281
|
+
if audio_ref:
|
|
282
|
+
try:
|
|
283
|
+
audio_src = self._resolve_input(audio_ref)
|
|
284
|
+
except (FileNotFoundError, ValueError) as e:
|
|
285
|
+
return self._error(str(e))
|
|
286
|
+
elif text_to_speak:
|
|
287
|
+
if self._speak_tool is None:
|
|
288
|
+
return self._error(
|
|
289
|
+
"`text` was supplied but the Speak tool is not wired "
|
|
290
|
+
"to this LongCatAvatar. Pass `audio` instead, or "
|
|
291
|
+
"ensure CognosAgent wired Speak into the tool."
|
|
292
|
+
)
|
|
293
|
+
speak_kwargs: dict[str, Any] = {"text": text_to_speak}
|
|
294
|
+
if kwargs.get("voice"):
|
|
295
|
+
speak_kwargs["voice"] = kwargs["voice"]
|
|
296
|
+
speak_result = await self._speak_tool.execute(**speak_kwargs)
|
|
297
|
+
status_val = getattr(speak_result, "status", None)
|
|
298
|
+
# ToolResult.status is an enum; compare by value
|
|
299
|
+
ok = status_val == "success" or (
|
|
300
|
+
hasattr(status_val, "value") and status_val.value == "success"
|
|
301
|
+
)
|
|
302
|
+
if not ok:
|
|
303
|
+
return self._error(
|
|
304
|
+
f"TTS step (Speak) failed: "
|
|
305
|
+
f"{getattr(speak_result, 'error', 'unknown error')}"
|
|
306
|
+
)
|
|
307
|
+
# Speak puts the URL into the output string; extract the
|
|
308
|
+
# file_id from the `.../files/<uuid>/content` segment.
|
|
309
|
+
# (ToolResult drops metadata kwargs silently in pydantic, so
|
|
310
|
+
# we can't rely on speak_result.metadata.)
|
|
311
|
+
output_text = str(getattr(speak_result, "output", "") or "")
|
|
312
|
+
m = re.search(r"/files/([0-9a-f-]{32,36})/content", output_text)
|
|
313
|
+
if not m:
|
|
314
|
+
return self._error(
|
|
315
|
+
"TTS step succeeded but could not find the audio "
|
|
316
|
+
"FileStore id in Speak's output; cannot proceed."
|
|
317
|
+
)
|
|
318
|
+
audio_file_id = m.group(1)
|
|
319
|
+
try:
|
|
320
|
+
audio_src = self._resolve_input(audio_file_id)
|
|
321
|
+
except (FileNotFoundError, ValueError) as e:
|
|
322
|
+
return self._error(f"Could not resolve TTS output: {e}")
|
|
323
|
+
else:
|
|
324
|
+
return self._error(
|
|
325
|
+
"Provide either `audio` (FileStore id or path) or "
|
|
326
|
+
"`text` (to synthesize speech). Neither was supplied."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# 2. Validate length
|
|
330
|
+
length = int(kwargs.get("length") or 77)
|
|
331
|
+
if (length - 1) % 4 != 0:
|
|
332
|
+
return self._error(
|
|
333
|
+
f"length must satisfy (length - 1) %% 4 == 0 (the temporal VAE requires it). "
|
|
334
|
+
f"Got {length}. Try 77, 81, 85, …, 121, …, 161."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# 3. Stage inputs into ComfyUI/input/
|
|
338
|
+
input_dir = COMFYUI_ROOT / "input"
|
|
339
|
+
try:
|
|
340
|
+
input_dir.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
return self._error(f"Cannot create ComfyUI input dir {input_dir}: {e}")
|
|
343
|
+
tag = uuid.uuid4().hex[:8]
|
|
344
|
+
ref_image_name = f"cognos_longcat_ref_{tag}{ref_image_src.suffix or '.png'}"
|
|
345
|
+
audio_name = f"cognos_longcat_audio_{tag}{audio_src.suffix or '.wav'}"
|
|
346
|
+
try:
|
|
347
|
+
shutil.copy2(ref_image_src, input_dir / ref_image_name)
|
|
348
|
+
shutil.copy2(audio_src, input_dir / audio_name)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
return self._error(f"Failed to stage inputs into ComfyUI: {e}")
|
|
351
|
+
|
|
352
|
+
# 4. Build workflow
|
|
353
|
+
try:
|
|
354
|
+
workflow = self._build_workflow(kwargs, ref_image_name, audio_name)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
return self._error(f"Failed to build workflow from template: {e}")
|
|
357
|
+
|
|
358
|
+
# 5. Run
|
|
359
|
+
async with httpx.AsyncClient() as client:
|
|
360
|
+
up_err = await self._ensure_comfyui_up(client)
|
|
361
|
+
if up_err is not None:
|
|
362
|
+
return self._error(up_err)
|
|
363
|
+
prompt_id, out_path, err = await self._submit_and_wait(client, workflow)
|
|
364
|
+
|
|
365
|
+
if err is not None:
|
|
366
|
+
return self._error(err)
|
|
367
|
+
if out_path is None or not out_path.exists():
|
|
368
|
+
return self._error(f"output file missing on disk: {out_path}")
|
|
369
|
+
|
|
370
|
+
# 6. Persist + return markdown link
|
|
371
|
+
try:
|
|
372
|
+
record = self._file_store.upload(out_path)
|
|
373
|
+
except Exception as e:
|
|
374
|
+
return self._error(f"FileStore upload of {out_path} failed: {e}")
|
|
375
|
+
url = f"{_public_base_url()}/files/{record.id}/content"
|
|
376
|
+
markdown = f"[{record.filename}]({url})" # video, not image — no leading !
|
|
377
|
+
return self._success({
|
|
378
|
+
"file_id": record.id,
|
|
379
|
+
"filename": record.filename,
|
|
380
|
+
"size_bytes": record.size_bytes,
|
|
381
|
+
"path": str(out_path),
|
|
382
|
+
"markdown": markdown,
|
|
383
|
+
"prompt_id": prompt_id,
|
|
384
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""MCP visibility tool — list connected Model Context Protocol servers.
|
|
2
|
+
|
|
3
|
+
Cognos already supports MCP at runtime: each `CognosAgent` keeps a
|
|
4
|
+
`_mcp_clients` list of connected `MCPClient`s, and the tools they
|
|
5
|
+
expose are merged into the executor's tool palette. This tool just
|
|
6
|
+
makes that surface visible from chat — the agent can ask "which MCP
|
|
7
|
+
servers are connected and what do they expose?" and get a clear
|
|
8
|
+
answer instead of having to dig through config files.
|
|
9
|
+
|
|
10
|
+
Configuration lives at `data/mcp_servers.json` (see the example in
|
|
11
|
+
the same dir for shape).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from core.schemas import ToolResult
|
|
20
|
+
from execution.tools.base import BaseTool
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCPListServersTool(BaseTool):
|
|
26
|
+
name = "MCPListServers"
|
|
27
|
+
description = (
|
|
28
|
+
"List all Model Context Protocol (MCP) servers currently "
|
|
29
|
+
"connected to this Cognos agent, plus the tools each one "
|
|
30
|
+
"exposes. Useful when the user asks 'what MCP servers do I "
|
|
31
|
+
"have' or 'why isn't tool X working' — this surfaces what's "
|
|
32
|
+
"actually wired vs. only configured. No arguments. Configure "
|
|
33
|
+
"MCP servers in `data/mcp_servers.json`."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def __init__(self, agent: Any | None = None):
|
|
37
|
+
self._agent = agent
|
|
38
|
+
|
|
39
|
+
def set_agent(self, agent: Any) -> None:
|
|
40
|
+
self._agent = agent
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def input_schema(self) -> dict[str, Any]:
|
|
44
|
+
return {"type": "object", "properties": {}}
|
|
45
|
+
|
|
46
|
+
async def execute(self, **_: Any) -> ToolResult:
|
|
47
|
+
if self._agent is None:
|
|
48
|
+
return ToolResult(
|
|
49
|
+
tool_name=self.name, status="error",
|
|
50
|
+
error=(
|
|
51
|
+
"MCPListServers is not wired to a CognosAgent. "
|
|
52
|
+
"The agent must call set_agent() at startup."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
clients = getattr(self._agent, "_mcp_clients", None)
|
|
56
|
+
if clients is None:
|
|
57
|
+
return ToolResult(
|
|
58
|
+
tool_name=self.name, status="success",
|
|
59
|
+
output="MCP support is not active in this agent.",
|
|
60
|
+
metadata={"clients": []},
|
|
61
|
+
)
|
|
62
|
+
if not clients:
|
|
63
|
+
return ToolResult(
|
|
64
|
+
tool_name=self.name, status="success",
|
|
65
|
+
output=(
|
|
66
|
+
"No MCP servers connected.\n"
|
|
67
|
+
"Configure one at `data/mcp_servers.json` (see "
|
|
68
|
+
"`data/mcp_servers.json.example` for the shape) "
|
|
69
|
+
"and restart Cognos."
|
|
70
|
+
),
|
|
71
|
+
metadata={"clients": []},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
lines = [f"{len(clients)} MCP server(s) connected:", ""]
|
|
75
|
+
client_meta: list[dict[str, Any]] = []
|
|
76
|
+
for c in clients:
|
|
77
|
+
name = getattr(c, "name", None) or repr(c)
|
|
78
|
+
tools = []
|
|
79
|
+
try:
|
|
80
|
+
tools = c.tools() or []
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.debug(f"MCP {name}: tools() failed: {e}")
|
|
83
|
+
tool_names = [
|
|
84
|
+
getattr(t, "name", None) or t.get("name", "?")
|
|
85
|
+
if isinstance(t, dict) else str(t)
|
|
86
|
+
for t in tools
|
|
87
|
+
]
|
|
88
|
+
lines.append(f" {name} ({len(tool_names)} tool(s))")
|
|
89
|
+
for tn in tool_names[:12]:
|
|
90
|
+
lines.append(f" - {tn}")
|
|
91
|
+
if len(tool_names) > 12:
|
|
92
|
+
lines.append(f" ... and {len(tool_names) - 12} more")
|
|
93
|
+
client_meta.append({"name": name, "tool_count": len(tool_names),
|
|
94
|
+
"tools": tool_names})
|
|
95
|
+
|
|
96
|
+
return ToolResult(
|
|
97
|
+
tool_name=self.name, status="success",
|
|
98
|
+
output="\n".join(lines),
|
|
99
|
+
metadata={"clients": client_meta},
|
|
100
|
+
)
|