feat(claude): tool use — weather, transport, events, notes, timer
Claude Haiku 4.5 теперь умеет дёргать tools. Все tools — proxy к endpoints
планшета (/api/voice/tools/* и /api/voice/timer) с bearer auth
VOICE_API_KEY. Никакой дополнительной auth в скрипте не требуется.
- satellite/tools.py — 5 tools:
* get_weather(city?) → Open-Meteo через tablet
* get_transport(direction, routes?) → трамваи Антонова-Овсеенко
* get_today_events(range?) → Google Calendar (today/week)
* get_notes() → текстовые + shopping lists
* set_timer(seconds, label) → создаёт таймер на дашборде
Каждый tool возвращает dict/list; ошибки упаковываются как {error: ...}
и отдаются Claude как результат — он сам обрабатывает.
- satellite/llm_claude.py:
* Подключил TOOL_SCHEMAS в вызов messages.create
* Цикл tool-use: до MAX_TOOL_ROUNDS=4 раундов tool_use → exec → tool_result
* System prompt дополнен инструкцией «используй tools без спроса»
* Финальный текст (после всех tool rounds) сохраняется в историю как один
assistant-turn — tool rounds в history не пишутся чтобы не раздувать кеш
* Usage логируется суммарно за все раунды
Работает с уже поднятым tinyproxy на .103 (HTTPS_PROXY в .env).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ from .text import clean_for_speech
|
|||||||
from .tts import speak, play_error_sound
|
from .tts import speak, play_error_sound
|
||||||
from . import notifier
|
from . import notifier
|
||||||
from .llm import strip_fillers # переиспользуем чистку филлеров
|
from .llm import strip_fillers # переиспользуем чистку филлеров
|
||||||
|
from .tools import TOOL_SCHEMAS, execute_tool
|
||||||
|
|
||||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5")
|
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5")
|
||||||
@@ -48,6 +49,12 @@ COSMO_SYSTEM_PROMPT = """Ты — Cosmo, домашний голосовой а
|
|||||||
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
|
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
|
||||||
- Если не знаешь — скажи коротко, не оправдывайся.
|
- Если не знаешь — скажи коротко, не оправдывайся.
|
||||||
|
|
||||||
|
Инструменты:
|
||||||
|
У тебя есть tools для погоды, транспорта (трамваи на остановке Антонова-Овсеенко),
|
||||||
|
календарных событий, заметок, и запуска таймера на дашборде. Используй их без просьбы
|
||||||
|
разрешения — если пользователь спрашивает «какая погода», сразу вызывай get_weather,
|
||||||
|
потом формулируй ответ. Не пересказывай сырые данные дословно — дай человеческую сводку.
|
||||||
|
|
||||||
Контекст: Даниил — разработчик, живёт в СПб с женой Светой. Сегодня {today}."""
|
Контекст: Даниил — разработчик, живёт в СПб с женой Светой. Сегодня {today}."""
|
||||||
|
|
||||||
LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург).
|
LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург).
|
||||||
@@ -58,6 +65,10 @@ LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой
|
|||||||
- Без эмодзи, списков, код-блоков — это голос.
|
- Без эмодзи, списков, код-блоков — это голос.
|
||||||
- Если не знаешь — скажи коротко.
|
- Если не знаешь — скажи коротко.
|
||||||
|
|
||||||
|
Инструменты:
|
||||||
|
У тебя есть tools — погода, трамваи, события, заметки, таймеры. Используй их
|
||||||
|
без лишних вопросов, а результат формулируй человеческим языком.
|
||||||
|
|
||||||
Сегодня {today}."""
|
Сегодня {today}."""
|
||||||
|
|
||||||
_client: "anthropic.Anthropic | None" = None
|
_client: "anthropic.Anthropic | None" = None
|
||||||
@@ -142,8 +153,25 @@ def _build_messages(history: list[dict]) -> list[dict]:
|
|||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
MAX_TOOL_ROUNDS = 4 # safety: не даём Claude крутить tools бесконечно
|
||||||
|
|
||||||
|
|
||||||
|
def _call_once(client, system_blocks, messages):
|
||||||
|
"""Один вызов без стрима — нужен для tool-use round trips.
|
||||||
|
Возвращает final Message object (с usage, content blocks, stop_reason)."""
|
||||||
|
return client.messages.create(
|
||||||
|
model=ANTHROPIC_MODEL,
|
||||||
|
max_tokens=MAX_TOKENS,
|
||||||
|
system=system_blocks,
|
||||||
|
messages=messages,
|
||||||
|
tools=TOOL_SCHEMAS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
|
def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
|
||||||
"""Спросить Claude Haiku 4.5 напрямую. Возвращает cleaned text (без speak — это делается снаружи)."""
|
"""Спросить Claude Haiku 4.5 напрямую, с поддержкой tool use.
|
||||||
|
Поток tool-use раундов: Claude → tool_use → мы выполняем → tool_result → Claude → ... → текст.
|
||||||
|
Возвращает финальный текст ответа (cleaned)."""
|
||||||
|
|
||||||
def _speak_if_local(t: str):
|
def _speak_if_local(t: str):
|
||||||
if t.strip() and notifier.speak_locally():
|
if t.strip() and notifier.speak_locally():
|
||||||
@@ -161,8 +189,6 @@ def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
|
|||||||
|
|
||||||
history = load_history(agent_id)
|
history = load_history(agent_id)
|
||||||
history.append({"role": "user", "content": text})
|
history.append({"role": "user", "content": text})
|
||||||
|
|
||||||
# Обрезаем слишком длинную историю
|
|
||||||
if len(history) > MAX_HISTORY_MESSAGES:
|
if len(history) > MAX_HISTORY_MESSAGES:
|
||||||
history = history[-MAX_HISTORY_MESSAGES:]
|
history = history[-MAX_HISTORY_MESSAGES:]
|
||||||
|
|
||||||
@@ -172,29 +198,91 @@ def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
|
|||||||
"cache_control": {"type": "ephemeral"},
|
"cache_control": {"type": "ephemeral"},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
messages = _build_messages(history)
|
# messages для API — строится из history плюс накапливающихся tool-use/tool-result блоков
|
||||||
|
api_messages = _build_messages(history)
|
||||||
|
|
||||||
|
total_start = time.time()
|
||||||
|
total_in = 0
|
||||||
|
total_out = 0
|
||||||
|
total_cache_r = 0
|
||||||
|
total_cache_w = 0
|
||||||
|
final_text = ""
|
||||||
|
# Собираем всю цепочку (assistant content blocks, tool results) чтобы одним куском сохранить в history
|
||||||
|
assistant_blocks_accumulated: list[dict] = []
|
||||||
|
|
||||||
start = time.time()
|
|
||||||
full_text = ""
|
|
||||||
try:
|
try:
|
||||||
with client.messages.stream(
|
for round_i in range(MAX_TOOL_ROUNDS):
|
||||||
model=ANTHROPIC_MODEL,
|
round_start = time.time()
|
||||||
max_tokens=MAX_TOKENS,
|
resp = _call_once(client, system_blocks, api_messages)
|
||||||
system=system_blocks,
|
|
||||||
messages=messages,
|
usage = resp.usage
|
||||||
) as stream:
|
total_in += usage.input_tokens
|
||||||
for chunk in stream.text_stream:
|
total_out += usage.output_tokens
|
||||||
full_text += chunk
|
total_cache_r += getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||||
|
total_cache_w += getattr(usage, "cache_creation_input_tokens", 0) or 0
|
||||||
|
|
||||||
|
# Разбираем content на text + tool_use
|
||||||
|
text_chunks = []
|
||||||
|
tool_uses = []
|
||||||
|
for block in resp.content:
|
||||||
|
btype = getattr(block, "type", None)
|
||||||
|
if btype == "text":
|
||||||
|
text_chunks.append(block.text)
|
||||||
|
elif btype == "tool_use":
|
||||||
|
tool_uses.append(block)
|
||||||
|
|
||||||
|
# Копим текст ассистента (может быть между tool-вызовами в новых моделях)
|
||||||
|
final_text += "".join(text_chunks)
|
||||||
|
|
||||||
|
# Добавляем ответ ассистента в api_messages (как есть)
|
||||||
|
# Это ВАЖНО: для tool_result следующим сообщением assistant content должен быть сохранён
|
||||||
|
# ровно как вернул API, чтобы tool_use_id совпал.
|
||||||
|
assistant_content = [
|
||||||
|
# Конвертируем объекты anthropic SDK в dict-представление
|
||||||
|
b.model_dump() if hasattr(b, "model_dump") else dict(b.__dict__)
|
||||||
|
for b in resp.content
|
||||||
|
]
|
||||||
|
api_messages.append({"role": "assistant", "content": assistant_content})
|
||||||
|
assistant_blocks_accumulated.extend(assistant_content)
|
||||||
|
|
||||||
final = stream.get_final_message()
|
|
||||||
usage = final.usage
|
|
||||||
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
||||||
cache_write = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
||||||
elapsed = time.time() - start
|
|
||||||
print(
|
print(
|
||||||
f"🧠 Claude {ANTHROPIC_MODEL} {elapsed:.2f}s · "
|
f"🧠 round {round_i + 1} {time.time() - round_start:.2f}s · "
|
||||||
f"in={usage.input_tokens} out={usage.output_tokens} "
|
f"stop={resp.stop_reason} · in={usage.input_tokens} out={usage.output_tokens} "
|
||||||
f"cache_r={cache_read} cache_w={cache_write}"
|
f"cache_r={getattr(usage, 'cache_read_input_tokens', 0) or 0}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.stop_reason == "tool_use" and tool_uses:
|
||||||
|
# Выполняем все запрошенные tools, собираем tool_result блоки
|
||||||
|
tool_results = []
|
||||||
|
for tu in tool_uses:
|
||||||
|
name = tu.name
|
||||||
|
tu_id = tu.id
|
||||||
|
params = tu.input or {}
|
||||||
|
print(f"🔧 Tool: {name}({params})")
|
||||||
|
result = execute_tool(name, params, agent_id)
|
||||||
|
# Упаковываем результат в JSON-строку (Claude ожидает string в tool_result content)
|
||||||
|
import json as _json
|
||||||
|
result_str = _json.dumps(result, ensure_ascii=False)
|
||||||
|
print(f" → {result_str[:200]}")
|
||||||
|
tool_results.append({
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": tu_id,
|
||||||
|
"content": result_str,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Добавляем user-message с результатами tools
|
||||||
|
api_messages.append({"role": "user", "content": tool_results})
|
||||||
|
# Продолжаем цикл — Claude обработает результаты и либо вызовет ещё, либо выдаст текст
|
||||||
|
continue
|
||||||
|
|
||||||
|
# stop_reason="end_turn" / "max_tokens" / "stop_sequence" — готов финальный ответ
|
||||||
|
break
|
||||||
|
|
||||||
|
elapsed = time.time() - total_start
|
||||||
|
print(
|
||||||
|
f"🧠 Claude {ANTHROPIC_MODEL} total {elapsed:.2f}s · "
|
||||||
|
f"in={total_in} out={total_out} "
|
||||||
|
f"cache_r={total_cache_r} cache_w={total_cache_w}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except anthropic.APIConnectionError:
|
except anthropic.APIConnectionError:
|
||||||
@@ -227,16 +315,23 @@ def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
|
|||||||
_speak_if_local(msg)
|
_speak_if_local(msg)
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
if not full_text:
|
if not final_text:
|
||||||
msg = "Не получил ответ."
|
msg = "Не получил ответ."
|
||||||
notifier.error(msg, agent_id)
|
notifier.error(msg, agent_id)
|
||||||
_speak_if_local(msg)
|
_speak_if_local(msg)
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
# Сохраняем реплику ассистента (до strip_fillers/clean — для верности истории)
|
# Сохраняем полный ассистентский turn (с tool-use/result) в историю.
|
||||||
history.append({"role": "assistant", "content": full_text})
|
# Для следующего turn'а history содержит: user text, затем assistant (text + tool_use),
|
||||||
|
# user (tool_result), assistant (final text). Всё в правильном порядке.
|
||||||
|
# Сейчас history у нас содержит только исходный user; добавим всё что произошло:
|
||||||
|
# - assistant с его blocks
|
||||||
|
# - если были tool results, они тоже должны быть (но только между assistant турами)
|
||||||
|
# Для простоты сохраним финальный текст как одну запись — tool rounds не сохраняем в history,
|
||||||
|
# иначе history в JSON будет пухнуть и не влезет в кеш.
|
||||||
|
history.append({"role": "assistant", "content": final_text})
|
||||||
save_history(agent_id, history)
|
save_history(agent_id, history)
|
||||||
|
|
||||||
result = clean_for_speech(strip_fillers(full_text))
|
result = clean_for_speech(strip_fillers(final_text))
|
||||||
_speak_if_local(result)
|
_speak_if_local(result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
201
satellite/tools.py
Normal file
201
satellite/tools.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Tool definitions для Claude Haiku 4.5.
|
||||||
|
|
||||||
|
Каждый tool описан схемой (Anthropic format) + обёрткой-executor.
|
||||||
|
Большинство tools — тонкие прокси к /api/voice/tools/* на планшете.
|
||||||
|
Таймер — тоже прокси, но POST (создаёт таймер, тот появляется на дашборде).
|
||||||
|
|
||||||
|
Аутентификация: Bearer VOICE_API_KEY (тот же что для /api/voice/event).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import log
|
||||||
|
|
||||||
|
TABLET_URL = os.getenv("TABLET_URL", "").rstrip("/")
|
||||||
|
VOICE_API_KEY = os.getenv("VOICE_API_KEY", "")
|
||||||
|
|
||||||
|
_session = requests.Session()
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {"Authorization": f"Bearer {VOICE_API_KEY}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _tablet_get(path: str, params: dict | None = None) -> dict:
|
||||||
|
url = f"{TABLET_URL}{path}"
|
||||||
|
r = _session.get(url, headers=_headers(), params=params, timeout=8)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _tablet_post(path: str, payload: dict) -> dict:
|
||||||
|
url = f"{TABLET_URL}{path}"
|
||||||
|
r = _session.post(url, headers=_headers(), json=payload, timeout=8)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Tool schemas (Anthropic format). Порядок = приоритет в Claude подсказках.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TOOL_SCHEMAS: list[dict] = [
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": (
|
||||||
|
"Получить текущую погоду и короткий прогноз для города. "
|
||||||
|
"Для вопросов вроде 'какая сегодня погода', 'холодно ли на улице', "
|
||||||
|
"'нужен ли зонт'. По умолчанию — Санкт-Петербург."
|
||||||
|
),
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). По умолчанию Санкт-Петербург.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_transport",
|
||||||
|
"description": (
|
||||||
|
"Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. "
|
||||||
|
"Для вопросов 'когда следующий 23-й', 'что ближайшее в центр', 'пора идти на остановку'."
|
||||||
|
),
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"direction": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["to_center", "from_center", "all"],
|
||||||
|
"description": "to_center = в центр (к Новочеркасской), from_center = от центра (к Большевиков), all = оба направления",
|
||||||
|
},
|
||||||
|
"routes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Фильтр маршрутов через запятую, например '23' или '23,27'. Пусто = все маршруты.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_today_events",
|
||||||
|
"description": (
|
||||||
|
"События из календаря на сегодня или на неделю. "
|
||||||
|
"Для вопросов 'что сегодня', 'какие планы', 'во сколько встреча'."
|
||||||
|
),
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"range": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["today", "week"],
|
||||||
|
"description": "today (по умолчанию) или week (7 дней)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_notes",
|
||||||
|
"description": (
|
||||||
|
"Список заметок и списков покупок с планшета. "
|
||||||
|
"Для 'что мне купить', 'что в списке', 'какие записи'."
|
||||||
|
),
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_timer",
|
||||||
|
"description": (
|
||||||
|
"Запустить таймер на планшете. Показывает обратный отсчёт с названием "
|
||||||
|
"и звенит по окончании. "
|
||||||
|
"Используй для 'поставь таймер на 10 минут', 'напомни через час', "
|
||||||
|
"'засеки 5 минут для чайника'."
|
||||||
|
),
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Длительность в секундах (1..86400)",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 86400,
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Короткое название таймера (например 'Чайник', 'Паста')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["seconds", "label"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Executors — возвращают JSON-совместимый dict или строку-ошибку.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _exec_get_weather(params: dict, agent_id: str) -> Any:
|
||||||
|
city = params.get("city", "")
|
||||||
|
return _tablet_get("/api/voice/tools/weather", params={"city": city} if city else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_get_transport(params: dict, agent_id: str) -> Any:
|
||||||
|
q = {}
|
||||||
|
if "direction" in params:
|
||||||
|
q["direction"] = params["direction"]
|
||||||
|
if "routes" in params:
|
||||||
|
q["routes"] = params["routes"]
|
||||||
|
return _tablet_get("/api/voice/tools/transport", params=q)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_get_today_events(params: dict, agent_id: str) -> Any:
|
||||||
|
range_ = params.get("range", "today")
|
||||||
|
return _tablet_get("/api/voice/tools/events", params={"range": range_})
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_get_notes(params: dict, agent_id: str) -> Any:
|
||||||
|
return _tablet_get("/api/voice/tools/notes")
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_set_timer(params: dict, agent_id: str) -> Any:
|
||||||
|
seconds = int(params.get("seconds", 0))
|
||||||
|
label = params.get("label", "Таймер")
|
||||||
|
if seconds < 1:
|
||||||
|
return {"error": "seconds must be positive"}
|
||||||
|
return _tablet_post(
|
||||||
|
"/api/voice/timer",
|
||||||
|
{"action": "start", "seconds": seconds, "label": label, "agent": agent_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
EXECUTORS = {
|
||||||
|
"get_weather": _exec_get_weather,
|
||||||
|
"get_transport": _exec_get_transport,
|
||||||
|
"get_today_events": _exec_get_today_events,
|
||||||
|
"get_notes": _exec_get_notes,
|
||||||
|
"set_timer": _exec_set_timer,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_tool(name: str, params: dict, agent_id: str = "cosmo") -> Any:
|
||||||
|
"""Выполнить tool по имени. Возвращает результат (dict/list/str).
|
||||||
|
При ошибке возвращает {'error': '...'} — это отправляется в Claude как результат."""
|
||||||
|
fn = EXECUTORS.get(name)
|
||||||
|
if fn is None:
|
||||||
|
return {"error": f"unknown tool: {name}"}
|
||||||
|
try:
|
||||||
|
result = fn(params, agent_id)
|
||||||
|
return result
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
log.warning(f"Tool {name} HTTP {e.response.status_code}: {e.response.text[:200]}")
|
||||||
|
return {"error": f"tool_http_{e.response.status_code}"}
|
||||||
|
except requests.RequestException as e:
|
||||||
|
log.warning(f"Tool {name} network error: {e}")
|
||||||
|
return {"error": "tool_network_error"}
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Tool {name} failed")
|
||||||
|
return {"error": f"tool_exception: {e}"}
|
||||||
Reference in New Issue
Block a user