diff --git a/agentui/api/server.py b/agentui/api/server.py index aea5864..4ac01bb 100644 --- a/agentui/api/server.py +++ b/agentui/api/server.py @@ -3,13 +3,14 @@ import logging from logging.handlers import RotatingFileHandler import json from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode, unquote -from fastapi.responses import JSONResponse, HTMLResponse +from fastapi.responses import JSONResponse, HTMLResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from typing import Any, Dict, List, Literal, Optional from agentui.pipeline.executor import PipelineExecutor from agentui.pipeline.defaults import default_pipeline from agentui.pipeline.storage import load_pipeline, save_pipeline, list_presets, load_preset, save_preset +from agentui.common.vendors import detect_vendor class UnifiedParams(BaseModel): @@ -38,17 +39,6 @@ class UnifiedChatRequest(BaseModel): metadata: Dict[str, Any] = Field(default_factory=dict) -def detect_vendor(payload: Dict[str, Any]) -> str: - if "anthropic_version" in payload or payload.get("provider") == "anthropic": - return "claude" - # Gemini typical payload keys - if "contents" in payload or "generationConfig" in payload: - return "gemini" - # OpenAI typical keys - if "messages" in payload or "model" in payload: - return "openai" - return "unknown" - def normalize_to_unified(payload: Dict[str, Any]) -> UnifiedChatRequest: vendor = detect_vendor(payload) @@ -278,6 +268,34 @@ def create_app() -> FastAPI: logger.addHandler(stream_handler) logger.addHandler(file_handler) + # --- Simple in-process SSE hub (subscriptions per browser tab) --- + import asyncio as _asyncio + + class _SSEHub: + def __init__(self) -> None: + self._subs: List[_asyncio.Queue] = [] + + def subscribe(self) -> _asyncio.Queue: + q: _asyncio.Queue = _asyncio.Queue() + self._subs.append(q) + return q + + def unsubscribe(self, q: _asyncio.Queue) -> None: + try: + self._subs.remove(q) + except ValueError: + pass + + async def publish(self, event: Dict[str, Any]) -> None: + # Fan-out to all subscribers; drop if queue is full + for q in list(self._subs): + try: + await q.put(event) + except Exception: + pass + + _trace_hub = _SSEHub() + def _mask_headers(h: Dict[str, Any]) -> Dict[str, Any]: # Временно отключаем маскировку Authorization для отладки hidden = {"x-api-key", "cookie"} @@ -369,7 +387,15 @@ def create_app() -> FastAPI: macro_ctx = build_macro_context(unified, incoming=incoming) pipeline = load_pipeline() executor = PipelineExecutor(pipeline) - last = await executor.run(macro_ctx) + + async def _trace(evt: Dict[str, Any]) -> None: + try: + base = {"pipeline_id": pipeline.get("id", "pipeline_editor")} + await _trace_hub.publish({**base, **evt}) + except Exception: + pass + + last = await executor.run(macro_ctx, trace=_trace) result = last.get("result") or await execute_pipeline_echo(unified) await _log_response(request, 200, result) return JSONResponse(result) @@ -402,7 +428,13 @@ def create_app() -> FastAPI: macro_ctx = build_macro_context(unified, incoming=incoming) pipeline = load_pipeline() executor = PipelineExecutor(pipeline) - last = await executor.run(macro_ctx) + async def _trace(evt: Dict[str, Any]) -> None: + try: + base = {"pipeline_id": pipeline.get("id", "pipeline_editor")} + await _trace_hub.publish({**base, **evt}) + except Exception: + pass + last = await executor.run(macro_ctx, trace=_trace) result = last.get("result") or await execute_pipeline_echo(unified) await _log_response(request, 200, result) return JSONResponse(result) @@ -431,7 +463,13 @@ def create_app() -> FastAPI: macro_ctx = build_macro_context(unified, incoming=incoming) pipeline = load_pipeline() executor = PipelineExecutor(pipeline) - last = await executor.run(macro_ctx) + async def _trace(evt: Dict[str, Any]) -> None: + try: + base = {"pipeline_id": pipeline.get("id", "pipeline_editor")} + await _trace_hub.publish({**base, **evt}) + except Exception: + pass + last = await executor.run(macro_ctx, trace=_trace) result = last.get("result") or await execute_pipeline_echo(unified) await _log_response(request, 200, result) return JSONResponse(result) @@ -465,7 +503,13 @@ def create_app() -> FastAPI: macro_ctx = build_macro_context(unified, incoming=incoming) pipeline = load_pipeline() executor = PipelineExecutor(pipeline) - last = await executor.run(macro_ctx) + async def _trace(evt: Dict[str, Any]) -> None: + try: + base = {"pipeline_id": pipeline.get("id", "pipeline_editor")} + await _trace_hub.publish({**base, **evt}) + except Exception: + pass + last = await executor.run(macro_ctx, trace=_trace) result = last.get("result") or await execute_pipeline_echo(unified) await _log_response(request, 200, result) return JSONResponse(result) @@ -498,7 +542,13 @@ def create_app() -> FastAPI: macro_ctx = build_macro_context(unified, incoming=incoming) pipeline = load_pipeline() executor = PipelineExecutor(pipeline) - last = await executor.run(macro_ctx) + async def _trace(evt: Dict[str, Any]) -> None: + try: + base = {"pipeline_id": pipeline.get("id", "pipeline_editor")} + await _trace_hub.publish({**base, **evt}) + except Exception: + pass + last = await executor.run(macro_ctx, trace=_trace) result = last.get("result") or await execute_pipeline_echo(unified) await _log_response(request, 200, result) return JSONResponse(result) @@ -532,11 +582,16 @@ def create_app() -> FastAPI: macro_ctx = build_macro_context(unified, incoming=incoming) pipeline = load_pipeline() executor = PipelineExecutor(pipeline) - last = await executor.run(macro_ctx) + async def _trace(evt: Dict[str, Any]) -> None: + try: + base = {"pipeline_id": pipeline.get("id", "pipeline_editor")} + await _trace_hub.publish({**base, **evt}) + except Exception: + pass + last = await executor.run(macro_ctx, trace=_trace) result = last.get("result") or await execute_pipeline_echo(unified) await _log_response(request, 200, result) return JSONResponse(result) - app.mount("/ui", StaticFiles(directory="static", html=True), name="ui") # Admin API для пайплайна @@ -580,6 +635,30 @@ def create_app() -> FastAPI: raise HTTPException(status_code=400, detail="Invalid pipeline format") save_preset(name, payload) return JSONResponse({"ok": True}) + # --- SSE endpoint for live pipeline trace --- + @app.get("/admin/trace/stream") + async def sse_trace() -> StreamingResponse: + loop = _asyncio.get_event_loop() + q = _trace_hub.subscribe() + + async def _gen(): + try: + # warm-up: send a comment to keep connection open + yield ":ok\n\n" + while True: + evt = await q.get() + try: + line = f"data: {json.dumps(evt, ensure_ascii=False)}\n\n" + except Exception: + line = "data: {}\n\n" + yield line + except Exception: + pass + finally: + _trace_hub.unsubscribe(q) + + return StreamingResponse(_gen(), media_type="text/event-stream") + return app diff --git a/agentui/common/__init__.py b/agentui/common/__init__.py new file mode 100644 index 0000000..595e6fd --- /dev/null +++ b/agentui/common/__init__.py @@ -0,0 +1 @@ +__all__ = ["vendors"] \ No newline at end of file diff --git a/agentui/common/vendors.py b/agentui/common/vendors.py new file mode 100644 index 0000000..39573d5 --- /dev/null +++ b/agentui/common/vendors.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Any, Dict + +__all__ = ["detect_vendor"] + + +def detect_vendor(payload: Dict[str, Any] | None) -> str: + """ + Определение вендора по форме payload. + Возвращает одно из: "openai" | "gemini" | "claude" | "unknown". + + Правила (порядок важен): + - Anthropic (Claude): + * наличие ключа "anthropic_version" (официальный заголовок/поле) + * явный маркер provider == "anthropic" + - Gemini: + * наличие "contents" или "generationConfig" (Google AI Studio / Vertex) + - OpenAI: + * наличие "messages" или "model" + - Фоллбэк: "unknown" + """ + if not isinstance(payload, dict): + return "unknown" + + # Явные подсказки, если заранее указали + hint = str(payload.get("vendor_format") or payload.get("vendor") or "").lower() + if hint in {"openai", "gemini", "claude"}: + return hint + + # Anthropic (Claude) + if "anthropic_version" in payload or payload.get("provider") == "anthropic": + return "claude" + + # Gemini (Google) + if "contents" in payload or "generationConfig" in payload: + return "gemini" + + # OpenAI + if "messages" in payload or "model" in payload: + return "openai" + + return "unknown" \ No newline at end of file diff --git a/agentui/pipeline/executor.py b/agentui/pipeline/executor.py index 62489c3..de8bbec 100644 --- a/agentui/pipeline/executor.py +++ b/agentui/pipeline/executor.py @@ -1,175 +1,37 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Callable, Awaitable from urllib.parse import urljoin import json import re import asyncio +import time from agentui.providers.http_client import build_client +from agentui.common.vendors import detect_vendor +from agentui.pipeline.templating import ( + _OUT_MACRO_RE, + _VAR_MACRO_RE, + _PROMPT_MACRO_RE, + _OUT_SHORT_RE, + _BRACES_RE, + _split_path, + _get_by_path, + _stringify_for_template, + _deep_find_text, + _best_text_from_outputs, + render_template_simple, +) -# --- Templating helpers ------------------------------------------------------ +# --- Templating helpers are imported from agentui.pipeline.templating --- -_OUT_MACRO_RE = re.compile(r"\[\[\s*OUT\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE) -_VAR_MACRO_RE = re.compile(r"\[\[\s*VAR\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE) -# Unified prompt fragment macro (provider-specific JSON fragment) -_PROMPT_MACRO_RE = re.compile(r"\[\[\s*PROMPT\s*\]\]", re.IGNORECASE) -# Short form: [[OUT1]] -> best-effort text from node n1 -_OUT_SHORT_RE = re.compile(r"\[\[\s*OUT\s*(\d+)\s*\]\]", re.IGNORECASE) -_BRACES_RE = re.compile(r"\{\{\s*([^}]+?)\s*\}\}") +# moved to agentui.pipeline.templating +# moved to agentui.pipeline.templating -def _split_path(path: str) -> List[str]: - return [p.strip() for p in str(path).split(".") if str(p).strip()] - - -def _get_by_path(obj: Any, path: Optional[str]) -> Any: - if path is None or path == "": - return obj - cur = obj - for seg in _split_path(path): - if isinstance(cur, dict): - if seg in cur: - cur = cur[seg] - else: - return None - elif isinstance(cur, list): - try: - idx = int(seg) - except Exception: # noqa: BLE001 - return None - if 0 <= idx < len(cur): - cur = cur[idx] - else: - return None - else: - return None - return cur - - -def _stringify_for_template(val: Any) -> str: - if val is None: - return "" - if isinstance(val, bool): - # JSON-friendly booleans (useful when embedding into JSON-like templates) - return "true" if val else "false" - if isinstance(val, (dict, list)): - try: - return json.dumps(val, ensure_ascii=False) - except Exception: # noqa: BLE001 - return str(val) - return str(val) - - -def _deep_find_text(obj: Any, max_nodes: int = 5000) -> Optional[str]: - """ - Best-effort поиск первого текстового значения в глубине структуры JSON. - Сначала пытаемся по ключам content/text, затем общий обход. - """ - try: - # Быстрые ветки - if isinstance(obj, str): - return obj - if isinstance(obj, dict): - c = obj.get("content") - if isinstance(c, str): - return c - t = obj.get("text") - if isinstance(t, str): - return t - parts = obj.get("parts") - if isinstance(parts, list) and parts: - for p in parts: - if isinstance(p, dict) and isinstance(p.get("text"), str): - return p.get("text") - - # Общий нерекурсивный обход в ширину - queue: List[Any] = [obj] - seen = 0 - while queue and seen < max_nodes: - cur = queue.pop(0) - seen += 1 - if isinstance(cur, str): - return cur - if isinstance(cur, dict): - # часто встречающиеся поля - for k in ("text", "content"): - v = cur.get(k) - if isinstance(v, str): - return v - # складываем все значения - for v in cur.values(): - queue.append(v) - elif isinstance(cur, list): - for it in cur: - queue.append(it) - except Exception: - pass - return None - - -def _best_text_from_outputs(node_out: Any) -> str: - """ - Унифицированное извлечение "текста" из выхода ноды. - Поддерживает: - - PromptTemplate: {"text": ...} - - LLMInvoke: {"response_text": ...} - - ProviderCall/RawForward: {"result": }, извлекаем текст для openai/gemini/claude - - Общий глубокий поиск текста, если специфичные ветки не сработали - """ - # Строка сразу - if isinstance(node_out, str): - return node_out - - if not isinstance(node_out, dict): - return "" - - # Явные короткие поля - if isinstance(node_out.get("response_text"), str) and node_out.get("response_text"): - return str(node_out["response_text"]) - if isinstance(node_out.get("text"), str) and node_out.get("text"): - return str(node_out["text"]) - - res = node_out.get("result") - base = res if isinstance(res, (dict, list)) else node_out - - # OpenAI - try: - if isinstance(base, dict): - ch0 = (base.get("choices") or [{}])[0] - msg = ch0.get("message") or {} - c = msg.get("content") - if isinstance(c, str): - return c - except Exception: - pass - - # Gemini - try: - if isinstance(base, dict): - cand0 = (base.get("candidates") or [{}])[0] - content = cand0.get("content") or {} - parts0 = (content.get("parts") or [{}])[0] - t = parts0.get("text") - if isinstance(t, str): - return t - except Exception: - pass - - # Claude - try: - if isinstance(base, dict): - blocks = base.get("content") or [] - texts = [b.get("text") for b in blocks if isinstance(b, dict) and isinstance(b.get("text"), str)] - if texts: - return "\n".join(texts) - except Exception: - pass - - # Общий глубокий поиск - txt = _deep_find_text(base) - return txt or "" +# moved to agentui.pipeline.templating +# moved to agentui.pipeline.templating def _extract_out_node_id_from_ref(s: Any) -> Optional[str]: """ @@ -190,12 +52,16 @@ def _resolve_in_value(source: Any, context: Dict[str, Any], values: Dict[str, Di """ Разрешает входные связи/макросы в значение для inputs: - Нестроковые значения возвращаются как есть - - "macro:path" → берёт значение из context по точечному пути - - "[[VAR:path]]" → берёт значение из context - - "[[OUT:nodeId(.path)*]]" → берёт из уже вычисленных выходов ноды - - "nodeId(.path)*" → ссылка на выходы ноды + - "macro:path" → берёт значение из context по точечному пути + - "[[VAR:path]]" → берёт значение из context + - "[[OUT:nodeId(.path)*]]" → берёт из уже вычисленных выходов ноды + - "nodeId(.path)*" → ссылка на выходы ноды + - Если передан список ссылок — вернёт список разрешённых значений - Иначе пытается взять из context по пути; если не найдено, оставляет исходную строку """ + # Поддержка массивов ссылок (для multi-depends или будущих списковых входов) + if isinstance(source, list): + return [_resolve_in_value(s, context, values) for s in source] if not isinstance(source, str): return source s = source.strip() @@ -235,100 +101,7 @@ def _resolve_in_value(source: Any, context: Dict[str, Any], values: Dict[str, Di return ctx_val if ctx_val is not None else source -def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> str: - """ - Простая подстановка: - - {{ path }} — берёт из context (или {{ OUT.node.path }} для выходов) - - Поддержка фильтра по умолчанию: {{ path|default(value) }} - value может быть числом, строкой ('..'/".."), массивом/объектом в виде литерала. - - [[VAR:path]] — берёт из context - - [[OUT:nodeId(.path)*]] — берёт из out_map - Возвращает строку. - """ - if template is None: - return "" - s = str(template) - - # 1) Макросы [[VAR:...]] и [[OUT:...]] - def repl_var(m: re.Match) -> str: - path = m.group(1).strip() - val = _get_by_path(context, path) - return _stringify_for_template(val) - - def repl_out(m: re.Match) -> str: - body = m.group(1).strip() - if "." in body: - node_id, rest = body.split(".", 1) - node_val = out_map.get(node_id) - val = _get_by_path(node_val, rest) - else: - val = out_map.get(body) - return _stringify_for_template(val) - - s = _VAR_MACRO_RE.sub(repl_var, s) - s = _OUT_MACRO_RE.sub(repl_out, s) - - # [[OUT1]] → текст из ноды n1 (best-effort) - def repl_out_short(m: re.Match) -> str: - try: - num = int(m.group(1)) - node_id = f"n{num}" - node_out = out_map.get(node_id) - txt = _best_text_from_outputs(node_out) - return _stringify_for_template(txt) - except Exception: - return "" - s = _OUT_SHORT_RE.sub(repl_out_short, s) - - # [[PROMPT]] expands to raw provider-specific JSON fragment prepared in context["PROMPT"] - s = _PROMPT_MACRO_RE.sub(lambda _m: str(context.get("PROMPT") or ""), s) - - # 2) Подстановки {{ ... }} (+ simple default filter) - def repl_braces(m: re.Match) -> str: - expr = m.group(1).strip() - - def eval_path(p: str) -> Any: - p = p.strip() - if p.startswith("OUT."): - body = p[4:] - if "." in body: - node_id, rest = body.split(".", 1) - node_val = out_map.get(node_id) - return _get_by_path(node_val, rest) - return out_map.get(body) - return _get_by_path(context, p) - - default_match = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", expr) - if default_match: - base_path = default_match.group(1).strip() - fallback_raw = default_match.group(2).strip() - # Снимем внешние кавычки, если это строковый литерал - if len(fallback_raw) >= 2 and ((fallback_raw[0] == "'" and fallback_raw[-1] == "'") or (fallback_raw[0] == '"' and fallback_raw[-1] == '"')): - fallback_val: Any = fallback_raw[1:-1] - else: - # Иначе оставляем как есть (числа/массивы/объекты — литералами) - fallback_val = fallback_raw - raw_val = eval_path(base_path) - val = raw_val if raw_val not in (None, "") else fallback_val - else: - val = eval_path(expr) - - return _stringify_for_template(val) - - s = _BRACES_RE.sub(repl_braces, s) - return s -def detect_vendor(payload: Dict[str, Any]) -> str: - if not isinstance(payload, dict): - return "unknown" - if "anthropic_version" in payload or payload.get("provider") == "anthropic": - return "claude" - # Gemini typical payload keys - if "contents" in payload or "generationConfig" in payload: - return "gemini" - # OpenAI typical keys - if "messages" in payload or "model" in payload: - return "openai" - return "unknown" +# moved to agentui.pipeline.templating class ExecutionError(Exception): @@ -360,7 +133,11 @@ class PipelineExecutor: raise ExecutionError(f"Unknown node type: {n.get('type')}") self.nodes_by_id[n["id"]] = node_cls(n["id"], n.get("config", {})) - async def run(self, context: Dict[str, Any]) -> Dict[str, Any]: + async def run( + self, + context: Dict[str, Any], + trace: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, + ) -> Dict[str, Any]: """ Исполнитель пайплайна с динамическим порядком на основе зависимостей графа. Новый режим: волновое (level-by-level) исполнение с параллелизмом и барьером. @@ -378,26 +155,29 @@ class PipelineExecutor: for n in nodes: nid = n["id"] for _, source in (n.get("in") or {}).items(): - if not isinstance(source, str): - # Нестрочные значения считаем константами — зависимостей нет - continue - if source.startswith("macro:"): - # Макросы берутся из контекста, без зависимостей - continue - # [[VAR:...]] — макрос из контекста, зависимостей нет - if re.fullmatch(r"\[\[\s*VAR\s*[:\s]\s*[^\]]+\s*\]\]", source.strip()): - continue - # [[OUT:nodeId(.key)*]] — зависимость от указанной ноды - out_ref_node = _extract_out_node_id_from_ref(source) - if out_ref_node and out_ref_node in id_set: - deps_map[nid].add(out_ref_node) - dependents[out_ref_node].add(nid) - continue - # Ссылки вида "node.outKey" или "node" - src_id = source.split(".", 1)[0] if "." in source else source - if src_id in id_set: - deps_map[nid].add(src_id) - dependents[src_id].add(nid) + # Разворачиваем массивы ссылок (multi-depends) + sources = source if isinstance(source, list) else [source] + for src in sources: + if not isinstance(src, str): + # Нестрочные значения и массивы констант — зависимостей нет + continue + if src.startswith("macro:"): + # Макросы берутся из контекста, без зависимостей + continue + # [[VAR:...]] — макрос из контекста, зависимостей нет + if re.fullmatch(r"\[\[\s*VAR\s*[:\s]\s*[^\]]+\s*\]\]", src.strip()): + continue + # [[OUT:nodeId(.key)*]] — зависимость от указанной ноды + out_ref_node = _extract_out_node_id_from_ref(src) + if out_ref_node and out_ref_node in id_set: + deps_map[nid].add(out_ref_node) + dependents[out_ref_node].add(nid) + continue + # Ссылки вида "node.outKey" или "node" + src_id = src.split(".", 1)[0] if "." in src else src + if src_id in id_set: + deps_map[nid].add(src_id) + dependents[src_id].add(nid) # Входящие степени и первая волна in_degree: Dict[str, int] = {nid: len(deps) for nid, deps in deps_map.items()} @@ -407,6 +187,8 @@ class PipelineExecutor: values: Dict[str, Dict[str, Any]] = {} last_result: Dict[str, Any] = {} node_def_by_id: Dict[str, Dict[str, Any]] = {n["id"]: n for n in nodes} + # Накопитель пользовательских переменных (SetVars) — доступен как context["vars"] + user_vars: Dict[str, Any] = {} # Параметры параллелизма try: @@ -417,49 +199,103 @@ class PipelineExecutor: parallel_limit = 1 # Вспомогательная корутина исполнения одной ноды со снапшотом OUT - async def exec_one(node_id: str, values_snapshot: Dict[str, Any]) -> tuple[str, Dict[str, Any]]: + async def exec_one(node_id: str, values_snapshot: Dict[str, Any], wave_num: int) -> tuple[str, Dict[str, Any]]: ndef = node_def_by_id.get(node_id) if not ndef: raise ExecutionError(f"Node definition not found: {node_id}") node = self.nodes_by_id[node_id] - + # Снимок контекста и OUT на момент старта волны ctx = dict(context) ctx["OUT"] = values_snapshot - + # Пользовательские переменные (накопленные SetVars) + try: + ctx["vars"] = dict(user_vars) + except Exception: + ctx["vars"] = {} + # Разрешаем inputs для ноды inputs: Dict[str, Any] = {} for name, source in (ndef.get("in") or {}).items(): - inputs[name] = _resolve_in_value(source, ctx, values_snapshot) + if isinstance(source, list): + inputs[name] = [_resolve_in_value(s, ctx, values_snapshot) for s in source] + else: + inputs[name] = _resolve_in_value(source, ctx, values_snapshot) - out = await node.run(inputs, ctx) - return node_id, out + # Трассировка старта + if trace is not None: + try: + await trace({"event": "node_start", "node_id": ndef["id"], "wave": wave_num, "ts": int(time.time() * 1000)}) + except Exception: + pass + + started = time.perf_counter() + try: + out = await node.run(inputs, ctx) + except Exception as exc: + if trace is not None: + try: + await trace({ + "event": "node_error", + "node_id": ndef["id"], + "wave": wave_num, + "ts": int(time.time() * 1000), + "error": str(exc), + }) + except Exception: + pass + raise + else: + dur_ms = int((time.perf_counter() - started) * 1000) + if trace is not None: + try: + await trace({ + "event": "node_done", + "node_id": ndef["id"], + "wave": wave_num, + "ts": int(time.time() * 1000), + "duration_ms": dur_ms, + }) + except Exception: + pass + return node_id, out # Волновое исполнение + wave_idx = 0 while ready: wave_nodes = list(ready) ready = [] # будет заполнено после завершения волны wave_results: Dict[str, Dict[str, Any]] = {} # Один общий снапшот OUT для всей волны (барьер — узлы волны не видят результаты друг друга) values_snapshot = dict(values) - + # Чанковый запуск с лимитом parallel_limit for i in range(0, len(wave_nodes), parallel_limit): chunk = wave_nodes[i : i + parallel_limit] # fail-fast: при исключении любой задачи gather бросит и отменит остальные results = await asyncio.gather( - *(exec_one(nid, values_snapshot) for nid in chunk), + *(exec_one(nid, values_snapshot, wave_idx) for nid in chunk), return_exceptions=False, ) # Коммитим результаты чанка в локальное хранилище волны for nid, out in results: wave_results[nid] = out last_result = out # обновляем на каждом успешном результате - + # После завершения волны — коммитим все её результаты в общие values values.update(wave_results) processed.extend(wave_nodes) - + + # Соберём пользовательские переменные из SetVars узлов волны + try: + for _nid, out in wave_results.items(): + if isinstance(out, dict): + v = out.get("vars") + if isinstance(v, dict): + user_vars.update(v) + except Exception: + pass + # Обновляем входящие степени для зависимых и формируем следующую волну for done in wave_nodes: for child in dependents.get(done, ()): @@ -467,6 +303,7 @@ class PipelineExecutor: next_ready = [nid for nid, deg in in_degree.items() if deg == 0 and nid not in processed and nid not in wave_nodes] # Исключаем уже учтённые и добавляем только те, которые действительно готовы ready = next_ready + wave_idx += 1 # Проверка на циклы/недостижимые ноды if len(processed) != len(nodes): @@ -476,6 +313,162 @@ class PipelineExecutor: return last_result +class SetVarsNode(Node): + type_name = "SetVars" + + def _normalize(self) -> List[Dict[str, Any]]: + raw = self.config.get("variables") or [] + if not isinstance(raw, list): + return [] + norm: List[Dict[str, Any]] = [] + for i, b in enumerate(raw): + if not isinstance(b, dict): + continue + name = str(b.get("name", "")).strip() + mode = str(b.get("mode", "string")).lower().strip() + value = b.get("value", "") + try: + order = int(b.get("order")) if b.get("order") is not None else i + except Exception: + order = i + norm.append({ + "id": b.get("id") or f"v{i}", + "name": name, + "mode": "expr" if mode == "expr" else "string", + "value": value, + "order": order, + }) + return norm + + def _safe_eval_expr(self, expr: str) -> Any: + """ + Безопасная оценка выражений для SetVars. + + Поддержка: + - Литералы: числа/строки/bool/None, списки, кортежи, словари + - JSON‑литералы: true/false/null, объекты и массивы (парсятся как Python True/False/None, dict/list) + - Арифметика: + - * / // %, унарные +- + - Логика: and/or, сравнения (== != < <= > >=, цепочки) + - Безопасные функции: rand(), randint(a,b), choice(list) + + Запрещено: имя/атрибуты/индексация/условные/импорты/прочие вызовы функций. + """ + import ast + import operator as op + import random + + # 0) Попытаться распознать чистый JSON‑литерал (включая true/false/null, объекты/массивы/числа/строки). + # Это не вмешивается в математику: для выражений вида "1+2" json.loads бросит исключение и мы пойдём в AST. + try: + s = str(expr).strip() + return json.loads(s) + except Exception: + pass + + allowed_bin = { + ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv, + ast.FloorDiv: op.floordiv, ast.Mod: op.mod, + } + allowed_unary = {ast.UAdd: lambda x: +x, ast.USub: lambda x: -x} + allowed_cmp = { + ast.Eq: op.eq, ast.NotEq: op.ne, ast.Lt: op.lt, ast.LtE: op.le, ast.Gt: op.gt, ast.GtE: op.ge, + } + + def eval_node(node: ast.AST) -> Any: + if isinstance(node, ast.Expression): + return eval_node(node.body) + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Tuple): + return tuple(eval_node(e) for e in node.elts) + if isinstance(node, ast.List): + return [eval_node(e) for e in node.elts] + if isinstance(node, ast.Dict): + return {eval_node(k): eval_node(v) for k, v in zip(node.keys, node.values)} + if isinstance(node, ast.UnaryOp) and type(node.op) in allowed_unary: + return allowed_unary[type(node.op)](eval_node(node.operand)) + if isinstance(node, ast.BinOp) and type(node.op) in allowed_bin: + return allowed_bin[type(node.op)](eval_node(node.left), eval_node(node.right)) + if isinstance(node, ast.BoolOp): + vals = [eval_node(v) for v in node.values] + if isinstance(node.op, ast.And): + res = True + for v in vals: + res = res and bool(v) + return res + if isinstance(node.op, ast.Or): + res = False + for v in vals: + res = res or bool(v) + return res + if isinstance(node, ast.Compare): + left = eval_node(node.left) + for opnode, comparator in zip(node.ops, node.comparators): + if type(opnode) not in allowed_cmp: + raise ExecutionError("Unsupported comparison operator") + right = eval_node(comparator) + if not allowed_cmp[type(opnode)](left, right): + return False + left = right + return True + # Разрешённые вызовы: rand(), randint(a,b), choice(list) + if isinstance(node, ast.Call): + # Никаких kwargs, *args + if node.keywords or isinstance(getattr(node, "starargs", None), ast.AST) or isinstance(getattr(node, "kwargs", None), ast.AST): + raise ExecutionError("Call with kwargs/starargs is not allowed") + fn = node.func + if not isinstance(fn, ast.Name): + raise ExecutionError("Only simple function calls are allowed") + name = fn.id + if name == "rand": + if len(node.args) != 0: + raise ExecutionError("rand() takes no arguments") + return random.random() + if name == "randint": + if len(node.args) != 2: + raise ExecutionError("randint(a,b) requires two arguments") + a = eval_node(node.args[0]) + b = eval_node(node.args[1]) + try: + return random.randint(int(a), int(b)) + except Exception as exc: # noqa: BLE001 + raise ExecutionError(f"randint invalid arguments: {exc}") + if name == "choice": + if len(node.args) != 1: + raise ExecutionError("choice(list) requires one argument") + seq = eval_node(node.args[0]) + if not isinstance(seq, (list, tuple)): + raise ExecutionError("choice() expects list or tuple") + if not seq: + raise ExecutionError("choice() on empty sequence") + return random.choice(seq) + raise ExecutionError(f"Function {name} is not allowed") + # Запрещаем всё остальное (Name/Attribute/Subscript/IfExp/Comprehensions и пр.) + raise ExecutionError("Expression not allowed") + + try: + tree = ast.parse(str(expr), mode="eval") + except Exception as exc: + raise ExecutionError(f"SetVars expr parse error: {exc}") from exc + return eval_node(tree) + + async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: # noqa: D401 + out_map = context.get("OUT") or {} + result: Dict[str, Any] = {} + import re as _re + for v in sorted(self._normalize(), key=lambda x: x.get("order", 0)): + name = v.get("name") or "" + if not _re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""): + raise ExecutionError(f"SetVars invalid variable name: {name!r}") + mode = v.get("mode", "string") + raw_val = v.get("value", "") + if mode == "expr": + resolved = self._safe_eval_expr(str(raw_val)) + else: + resolved = render_template_simple(str(raw_val or ""), context, out_map) + result[name] = resolved + return {"vars": result} + class ProviderCallNode(Node): type_name = "ProviderCall" @@ -520,9 +513,8 @@ class ProviderCallNode(Node): messages: List[Dict[str, Any]] = [] for b in blocks: content = render_template_simple(str(b.get("prompt") or ""), context, out_map) + # name поля блоков не передаются в провайдерские payload'ы msg = {"role": b["role"], "content": content} - if b.get("name"): - msg["name"] = b["name"] messages.append(msg) return messages @@ -534,7 +526,7 @@ class ProviderCallNode(Node): payload: Dict[str, Any] = { "model": model, "messages": [ - {k: v for k, v in {"role": m["role"], "content": m["content"], "name": m.get("name")}.items() if v is not None} + {"role": m["role"], "content": m["content"]} for m in messages ], "temperature": params.get("temperature", 0.7), @@ -582,7 +574,6 @@ class ProviderCallNode(Node): payload: Dict[str, Any] = { "model": model, "messages": msgs, - "anthropic_version": context.get("anthropic_version", "2023-06-01"), } if sys_text: payload["system"] = sys_text @@ -611,12 +602,12 @@ class ProviderCallNode(Node): msgs = messages or [] if provider == "openai": - # Уже в формате {"role","content","name?"} + # Уже в формате {"role","content"} sys_text = "\n\n".join([m["content"] for m in msgs if m.get("role") == "system"]).strip() # Вставляем как есть (editor будет встраивать JSON массива без кавычек) return { "messages": [ - {k: v for k, v in {"role": m["role"], "content": m.get("content"), "name": m.get("name")}.items() if v is not None} + {"role": m["role"], "content": m.get("content")} for m in msgs ], "system_text": sys_text, @@ -630,12 +621,13 @@ class ProviderCallNode(Node): continue role = "model" if m.get("role") == "assistant" else "user" contents.append({"role": role, "parts": [{"text": str(m.get("content") or "")}]}) - sys_instr = {"parts": [{"text": sys_text}]} if sys_text else {} # всегда корректный JSON-объект - return { + d: Dict[str, Any] = { "contents": contents, - "systemInstruction": sys_instr, "system_text": sys_text, } + if sys_text: + d["systemInstruction"] = {"parts": [{"text": sys_text}]} + return d if provider == "claude": sys_text = "\n\n".join([m["content"] for m in msgs if m.get("role") == "system"]).strip() @@ -729,13 +721,23 @@ class ProviderCallNode(Node): # Рендер endpoint с макросами/шаблонами endpoint = render(endpoint_tmpl) - # Формируем тело ТОЛЬКО из template/[[PROMPT]] (без сырого payload/входов) + # Формируем тело ТОЛЬКО из template/[[PROMPT]] (без сырого payload/входов). + # Больше НИКАКОГО фоллбэка на unified-построение: если шаблон невалиден — это ошибка ноды. try: rendered = render(template) + # DEBUG: печать отрендеренного шаблона с номерами строк для точной диагностики JSONDecodeError + try: + _lines = rendered.splitlines() + _preview = "\n".join(f"{i+1:03d}: {_lines[i]}" for i in range(min(len(_lines), 120))) + print(f"DEBUG: ProviderCallNode rendered_template node={self.node_id} provider={provider}\\n{_preview}") + except Exception: + try: + print(f"DEBUG: ProviderCallNode rendered_template(node={self.node_id}, provider={provider}) len={len(rendered)}") + except Exception: + pass payload = json.loads(rendered) - except Exception: - # Fallback: используем генерацию из Prompt Blocks в формате провайдера - payload = self._messages_to_payload(provider, unified_msgs, context) + except Exception as exc: # noqa: BLE001 + raise ExecutionError(f"ProviderCall template invalid JSON: {exc}") # Заголовки — полностью из редактируемого JSON с макросами try: @@ -752,22 +754,55 @@ class ProviderCallNode(Node): url = endpoint if endpoint.startswith("http") else urljoin(base_url.rstrip('/') + '/', endpoint.lstrip('/')) # Debug logs to validate config selection and payload + # Brute request/response logging (FULL, no masking) try: - payload_preview = "" + final_headers = {"Content-Type": "application/json", **headers} + print("===== ProviderCall REQUEST BEGIN =====") + print(f"node={self.node_id} type={self.type_name} provider={provider}") + print(f"URL: {url}") try: - payload_preview = json.dumps(payload, ensure_ascii=False)[:400] + print("Headers:") + print(json.dumps(final_headers, ensure_ascii=False, indent=2)) except Exception: - payload_preview = str(payload)[:400] - print(f"DEBUG: ProviderCallNode provider={provider} URL={url}") - print(f"DEBUG: ProviderCallNode headers_keys={list(headers.keys())}") - print(f"DEBUG: ProviderCallNode payload_preview={payload_preview}") + print(f"Headers(raw): {final_headers}") + try: + print("Body JSON:") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + except Exception: + print(f"Body(raw): {payload}") + print("===== ProviderCall REQUEST END =====") except Exception: pass async with build_client() as client: - resp = await client.post(url, json=payload, headers={"Content-Type": "application/json", **headers}) - resp.raise_for_status() - data = resp.json() + resp = await client.post(url, json=payload, headers=final_headers) + # Do not raise_for_status: keep body/logs on 4xx/5xx + try: + print("===== ProviderCall RESPONSE BEGIN =====") + print(f"node={self.node_id} type={self.type_name} provider={provider}") + print(f"Status: {resp.status_code}") + try: + print("Headers:") + print(json.dumps(dict(resp.headers), ensure_ascii=False, indent=2)) + except Exception: + try: + print(f"Headers(raw): {dict(resp.headers)}") + except Exception: + print("Headers(raw): ") + try: + body_text = resp.text + except Exception: + body_text = "" + print("Body Text:") + print(body_text) + print("===== ProviderCall RESPONSE END =====") + except Exception: + pass + + try: + data = resp.json() + except Exception: + data = {"error": "Failed to decode JSON from upstream", "text": resp.text} # Извлекаем текст best-effort text = None @@ -860,29 +895,143 @@ class RawForwardNode(Node): headers.update(extra_headers) - print(f"DEBUG: RawForwardNode sending request to URL: {url}") - print(f"DEBUG: RawForwardNode sending with HEADERS: {headers}") + # Brute request/response logging (FULL, no masking) + try: + print("===== RawForward REQUEST BEGIN =====") + print(f"node={self.node_id} type={self.type_name}") + print(f"URL: {url}") + try: + print("Headers:") + print(json.dumps(headers, ensure_ascii=False, indent=2)) + except Exception: + print(f"Headers(raw): {headers}") + try: + print("Body JSON:") + print(json.dumps(raw_payload, ensure_ascii=False, indent=2)) + except Exception: + print(f"Body(raw): {raw_payload}") + print("===== RawForward REQUEST END =====") + except Exception: + pass async with build_client() as client: resp = await client.post(url, json=raw_payload, headers=headers) - - # Логируем ответ от целевого API для диагностики + + # Response logging + try: + print("===== RawForward RESPONSE BEGIN =====") + print(f"node={self.node_id} type={self.type_name}") + print(f"Status: {resp.status_code}") + try: + print("Headers:") + print(json.dumps(dict(resp.headers), ensure_ascii=False, indent=2)) + except Exception: + try: + print(f"Headers(raw): {dict(resp.headers)}") + except Exception: + print("Headers(raw): ") + try: + body_text = resp.text + except Exception: + body_text = "" + print("Body Text:") + print(body_text) + print("===== RawForward RESPONSE END =====") + except Exception: + pass + + # Decode JSON if possible, otherwise return text try: data = resp.json() - print(f"DEBUG: RawForwardNode received response. Status: {resp.status_code}, Body: {data}") except Exception: data = {"error": "Failed to decode JSON from upstream", "text": resp.text} - print(f"DEBUG: RawForwardNode received non-JSON response. Status: {resp.status_code}, Text: {resp.text}") + return {"result": data} - # Не выбрасываем исключение, а просто проксируем ответ - # resp.raise_for_status() - - return {"result": data} + +class ReturnNode(Node): + type_name = "Return" + + async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: # noqa: D401 + # Определяем целевой формат + cfg = self.config or {} + target = str(cfg.get("target_format", "auto")).lower().strip() or "auto" + if target == "auto": + target = str(context.get("vendor_format") or "openai").lower().strip() or "openai" + + # Рендерим текст из шаблона (по умолчанию берём [[OUT1]]) + out_map = context.get("OUT") or {} + template = cfg.get("text_template") + if template is None or template == "": + template = "[[OUT1]]" + try: + text = render_template_simple(str(template), context, out_map) + except Exception: + text = "" + + model = str(context.get("model") or "") + + # Форматтеры под провайдеры (как в execute_pipeline_echo) + def fmt_openai(t: str) -> Dict[str, Any]: + return { + "id": "ret_mock_123", + "object": "chat.completion", + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": t}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": len((t or "").split()), "total_tokens": 0}, + } + + def fmt_gemini(t: str) -> Dict[str, Any]: + return { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": t}], + }, + "finishReason": "STOP", + "index": 0, + } + ], + "modelVersion": model, + } + + def fmt_claude(t: str) -> Dict[str, Any]: + return { + "id": "msg_ret_123", + "type": "message", + "model": model, + "role": "assistant", + "content": [ + {"type": "text", "text": t} + ], + "stop_reason": "end_turn", + } + + if target == "openai": + result = fmt_openai(text) + elif target == "gemini": + result = fmt_gemini(text) + elif target == "claude": + result = fmt_claude(text) + else: + # неизвестное значение — безопасный дефолт + result = fmt_openai(text) + + return {"result": result, "response_text": text} + NODE_REGISTRY.update({ + SetVarsNode.type_name: SetVarsNode, ProviderCallNode.type_name: ProviderCallNode, RawForwardNode.type_name: RawForwardNode, + ReturnNode.type_name: ReturnNode, }) - + diff --git a/agentui/pipeline/templating.py b/agentui/pipeline/templating.py new file mode 100644 index 0000000..3ebda39 --- /dev/null +++ b/agentui/pipeline/templating.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional + +__all__ = [ + "_OUT_MACRO_RE", + "_VAR_MACRO_RE", + "_PROMPT_MACRO_RE", + "_OUT_SHORT_RE", + "_BARE_MACRO_RE", + "_BRACES_RE", + "_split_path", + "_get_by_path", + "_stringify_for_template", + "_deep_find_text", + "_best_text_from_outputs", + "render_template_simple", +] + +# Regex-макросы (общие для бэка) +_OUT_MACRO_RE = re.compile(r"\[\[\s*OUT\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE) +_VAR_MACRO_RE = re.compile(r"\[\[\s*VAR\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE) +# Единый фрагмент PROMPT (провайдеро-специфичный JSON-фрагмент) +_PROMPT_MACRO_RE = re.compile(r"\[\[\s*PROMPT\s*\]\]", re.IGNORECASE) +# Короткая форма: [[OUT1]] — best-effort текст из ноды n1 +_OUT_SHORT_RE = re.compile(r"\[\[\s*OUT\s*(\d+)\s*\]\]", re.IGNORECASE) +# Голые переменные: [[NAME]] или [[path.to.value]] — сначала ищем в vars, затем в контексте +_BARE_MACRO_RE = re.compile(r"\[\[\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[^\]]+?)?)\s*\]\]") +# Подстановки {{ ... }} (включая простейший фильтр |default(...)) +_BRACES_RE = re.compile(r"\{\{\s*([^}]+?)\s*\}\}") + + +def _split_path(path: str) -> List[str]: + return [p.strip() for p in str(path).split(".") if str(p).strip()] + + +def _get_by_path(obj: Any, path: Optional[str]) -> Any: + if path is None or path == "": + return obj + cur = obj + for seg in _split_path(path): + if isinstance(cur, dict): + if seg in cur: + cur = cur[seg] + else: + return None + elif isinstance(cur, list): + try: + idx = int(seg) + except Exception: # noqa: BLE001 + return None + if 0 <= idx < len(cur): + cur = cur[idx] + else: + return None + else: + return None + return cur + + +def _stringify_for_template(val: Any) -> str: + if val is None: + return "" + if isinstance(val, bool): + # JSON-friendly booleans (useful when embedding into JSON-like templates) + return "true" if val else "false" + if isinstance(val, (dict, list)): + try: + return json.dumps(val, ensure_ascii=False) + except Exception: # noqa: BLE001 + return str(val) + return str(val) + + +def _deep_find_text(obj: Any, max_nodes: int = 5000) -> Optional[str]: + """ + Best-effort поиск первого текстового значения в глубине структуры JSON. + Сначала пытаемся по ключам content/text, затем общий обход. + """ + try: + # Быстрые ветки + if isinstance(obj, str): + return obj + if isinstance(obj, dict): + c = obj.get("content") + if isinstance(c, str): + return c + t = obj.get("text") + if isinstance(t, str): + return t + parts = obj.get("parts") + if isinstance(parts, list) and parts: + for p in parts: + if isinstance(p, dict) and isinstance(p.get("text"), str): + return p.get("text") + + # Общий нерекурсивный обход в ширину + queue: List[Any] = [obj] + seen = 0 + while queue and seen < max_nodes: + cur = queue.pop(0) + seen += 1 + if isinstance(cur, str): + return cur + if isinstance(cur, dict): + # часто встречающиеся поля + for k in ("text", "content"): + v = cur.get(k) + if isinstance(v, str): + return v + # складываем все значения + for v in cur.values(): + queue.append(v) + elif isinstance(cur, list): + for it in cur: + queue.append(it) + except Exception: + pass + return None + + +def _best_text_from_outputs(node_out: Any) -> str: + """ + Унифицированное извлечение "текста" из выхода ноды. + Поддерживает: + - PromptTemplate: {"text": ...} + - LLMInvoke: {"response_text": ...} + - ProviderCall/RawForward: {"result": }, извлекаем текст для openai/gemini/claude + - Общий глубокий поиск текста, если специфичные ветки не сработали + """ + # Строка сразу + if isinstance(node_out, str): + return node_out + + if not isinstance(node_out, dict): + return "" + + # Явные короткие поля + if isinstance(node_out.get("response_text"), str) and node_out.get("response_text"): + return str(node_out["response_text"]) + if isinstance(node_out.get("text"), str) and node_out.get("text"): + return str(node_out["text"]) + + res = node_out.get("result") + base = res if isinstance(res, (dict, list)) else node_out + + # OpenAI + try: + if isinstance(base, dict): + ch0 = (base.get("choices") or [{}])[0] + msg = ch0.get("message") or {} + c = msg.get("content") + if isinstance(c, str): + return c + except Exception: + pass + + # Gemini + try: + if isinstance(base, dict): + cand0 = (base.get("candidates") or [{}])[0] + content = cand0.get("content") or {} + parts0 = (content.get("parts") or [{}])[0] + t = parts0.get("text") + if isinstance(t, str): + return t + except Exception: + pass + + # Claude + try: + if isinstance(base, dict): + blocks = base.get("content") or [] + texts = [b.get("text") for b in blocks if isinstance(b, dict) and isinstance(b.get("text"), str)] + if texts: + return "\n".join(texts) + except Exception: + pass + + # Общий глубокий поиск + txt = _deep_find_text(base) + return txt or "" + + +def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> str: + """ + Простая подстановка: + - {{ path }} — берёт из context (или {{ OUT.node.path }} для выходов) + - Поддержка фильтра по умолчанию: {{ path|default(value) }} + value может быть числом, строкой ('..'/".."), массивом/объектом в виде литерала. + - [[VAR:path]] — берёт из context + - [[OUT:nodeId(.path)*]] — берёт из out_map + Возвращает строку. + """ + if template is None: + return "" + s = str(template) + + # 1) Макросы [[VAR:...]] и [[OUT:...]] + def repl_var(m: re.Match) -> str: + path = m.group(1).strip() + val = _get_by_path(context, path) + return _stringify_for_template(val) + + def repl_out(m: re.Match) -> str: + body = m.group(1).strip() + if "." in body: + node_id, rest = body.split(".", 1) + node_val = out_map.get(node_id) + val = _get_by_path(node_val, rest) + else: + val = out_map.get(body) + return _stringify_for_template(val) + + s = _VAR_MACRO_RE.sub(repl_var, s) + s = _OUT_MACRO_RE.sub(repl_out, s) + + # [[OUT1]] → текст из ноды n1 (best-effort) + def repl_out_short(m: re.Match) -> str: + try: + num = int(m.group(1)) + node_id = f"n{num}" + node_out = out_map.get(node_id) + txt = _best_text_from_outputs(node_out) + return _stringify_for_template(txt) + except Exception: + return "" + s = _OUT_SHORT_RE.sub(repl_out_short, s) + + # [[PROMPT]] — провайдеро-специфичный JSON-фрагмент, подготовленный в context["PROMPT"] + s = _PROMPT_MACRO_RE.sub(lambda _m: str(context.get("PROMPT") or ""), s) + + # 1.5) Голые [[NAME]] / [[path.to.value]] + def repl_bare(m: re.Match) -> str: + name = m.group(1).strip() + # Зарезервированные формы уже обработаны выше; бережно пропускаем похожие + if name.upper() in {"OUT", "VAR", "PROMPT"} or re.fullmatch(r"OUT\d+", name.upper() or ""): + return m.group(0) + # Сначала пользовательские переменные + vmap = context.get("vars") or {} + if isinstance(vmap, dict) and name in vmap: + return _stringify_for_template(vmap.get(name)) + # Затем путь из общего контекста + val = _get_by_path(context, name) + return _stringify_for_template(val) + s = _BARE_MACRO_RE.sub(repl_bare, s) + + # 2) Подстановки {{ ... }} (+ simple default filter) + def repl_braces(m: re.Match) -> str: + expr = m.group(1).strip() + + def eval_path(p: str) -> Any: + p = p.strip() + # Приоритет пользовательских переменных для простых идентификаторов {{ NAME }} + vmap = context.get("vars") or {} + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", p) and isinstance(vmap, dict) and p in vmap: + return vmap.get(p) + if p.startswith("OUT."): + body = p[4:] + if "." in body: + node_id, rest = body.split(".", 1) + node_val = out_map.get(node_id) + return _get_by_path(node_val, rest) + return out_map.get(body) + return _get_by_path(context, p) + + default_match = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", expr) + if default_match: + base_path = default_match.group(1).strip() + fallback_raw = default_match.group(2).strip() + + # Рекурсивная обработка вложенных default(...) и путей + def eval_default(raw: str) -> Any: + raw = raw.strip() + # Вложенный default: a|default(b) + dm = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", raw) + if dm: + base2 = dm.group(1).strip() + fb2 = dm.group(2).strip() + v2 = eval_path(base2) + if v2 not in (None, ""): + return v2 + return eval_default(fb2) + # Пробуем как путь + v = eval_path(raw) + if v not in (None, ""): + return v + # Явная строка в кавычках + if len(raw) >= 2 and ((raw[0] == '"' and raw[-1] == '"') or (raw[0] == "'" and raw[-1] == "'")): + return raw[1:-1] + # Пробуем распарсить как JSON литерал (число/объект/массив/true/false/null) + try: + return json.loads(raw) + except Exception: + # Последний вариант: вернуть сырой текст. Для строк рекомендуется default('...') с кавычками. + return raw + + raw_val = eval_path(base_path) + val = raw_val if raw_val not in (None, "") else eval_default(fallback_raw) + else: + val = eval_path(expr) + + return _stringify_for_template(val) + + s = _BRACES_RE.sub(repl_braces, s) + return s \ No newline at end of file diff --git a/docs/VARIABLES.md b/docs/VARIABLES.md index 2ade2af..cc8c2f2 100644 --- a/docs/VARIABLES.md +++ b/docs/VARIABLES.md @@ -1,95 +1,148 @@ -# Переменные и макросы AgentUI +# Переменные и макросы НадTavern -Этот файл — простая шпаргалка по переменным/макросам, которые можно использовать в шаблонах узла ProviderCall и в Prompt Blocks. +Краткая, человеко‑понятная шпаргалка по тому, какие переменные и макросы доступны в шаблонах (в том числе в Prompt Blocks), как они устроены и как их правильно использовать. Док ниже соответствует текущему коду. -Правила ввода: -- Квадратные макросы [[...]] — простая подстановка. Хорошо подходят для строк и для URL/заголовков. -- Фигурные {{ ... }} — «джинджа‑лайт»: умеют фильтр |default(...), корректно вставляют объекты и массивы внутрь JSON без лишних кавычек. -- Любые значения, вставляемые в JSON через макросы, приводятся к корректному JSON когда это возможно. +Реализация формирует единый «контекст» переменных для всех нод пайплайна, дополняет его выходами уже выполненных нод, а узел ProviderCall добавляет свои служебные структуры для удобной сборки промпта. -Служебные файлы/строки реализации: -- Рендеринг и макросы: [render_template_simple()](agentui/pipeline/executor.py:125) -- Провайдерный узел с формированием PROMPT: [ProviderCallNode.run()](agentui/pipeline/executor.py:565) +Ссылки на код: +- Формирование контекста запроса: [build_macro_context()](agentui/api/server.py:142) +- Исполнитель пайплайна и снапшот OUT: [PipelineExecutor.run()](agentui/pipeline/executor.py:136) +- Узел провайдера (Prompt Blocks → provider payload): [ProviderCallNode.run()](agentui/pipeline/executor.py:650) +- Шаблоны/макросы ([[...]] и {{ ... }}): [render_template_simple()](agentui/pipeline/templating.py:187) +- Короткая форма [[OUTx]] (извлечение текста): [_best_text_from_outputs()](agentui/pipeline/templating.py:124) +- Прямой форвард запросов: [RawForwardNode.run()](agentui/pipeline/executor.py:833) --- -## Общие переменные контекста +## 1) Общие переменные контекста (для всех нод) -- [[model]] — активная модель (строка) -- [[vendor_format]] — openai | gemini | claude | unknown -- [[system]] — системный текст, если был во входящем запросе -- [[params.temperature]], [[params.max_tokens]], [[params.top_p]], [[params.stop]] -- [[chat.last_user]] — последнее user‑сообщение -- [[chat.messages]] — массив унифицированных сообщений -- [[incoming.path]] — путь входящего HTTP‑запроса -- [[incoming.query]] — строка query (?a=1&b=2) -- [[incoming.query_params]] — объект query, например {"key":"..."} -- [[incoming.headers]] — заголовки входящего запроса -- [[incoming.json]] — JSON‑тело входящего запроса клиента -- [[incoming.api_keys.authorization]] — значение Authorization (если есть) -- [[incoming.api_keys.key]] — значение ?key=... в URL (удобно для Gemini) -- [[incoming.api_keys.secret]] — запасной слот +Эти переменные доступны в шаблонах любой ноды. Они добавляются на стороне сервера при обработке входящего HTTP‑запроса. -Те же поля доступны через {{ ... }}: например {{ params.temperature|default(0.7) }}, {{ incoming.json }} и т.д. +- model — строка с именем модели. + Пример: "gpt-4o-mini" +- vendor_format — вендор/протокол запроса: "openai" | "gemini" | "claude" | "unknown" +- system — «системный» текст, если он был во входящем запросе; иначе пустая строка. + +- params — стандартные параметры генерации (можно использовать как дефолты) + - params.temperature — число с плавающей точкой (по умолчанию 0.7) + - params.max_tokens — целое или null + - params.top_p — число (по умолчанию 1.0) + - params.stop — массив строк или null + +- chat — сведения о чате во входящем запросе + - chat.last_user — последнее сообщение пользователя (строка) + - chat.messages — массив сообщений в унифицированной форме: + - role — "system" | "user" | "assistant" | "tool" + - content — содержимое (обычно строка) + - name — опционально, строка + - tool_call_id — опционально + +- incoming — детали ВХОДЯЩЕГО HTTP‑запроса + - incoming.method — метод ("POST" и т.п.) + - incoming.url — полный URL (в query ключи маскируются для логов) + - incoming.path — путь (например, /v1/chat/completions) + - incoming.query — строка query без вопросительного знака + - incoming.query_params — объект со всеми query‑параметрами + - incoming.headers — объект всех заголовков запроса + - incoming.json — сырой JSON тела запроса, как прислал клиент + - incoming.api_keys — удобные «срезы» ключей + - incoming.api_keys.authorization — значение из заголовка Authorization (если есть) + - incoming.api_keys.key — значение из query (?key=...) — удобно для Gemini + +Пример использования в шаблоне: +- [[VAR:incoming.api_keys.key]] — возьмёт ключ из строки запроса (?key=...). +- [[VAR:incoming.headers.x-api-key]] — возьмёт ключ из заголовка x-api-key (типично для Anthropic). +- {{ params.temperature|default(0.7) }} — безопасно подставит число, если не задано во входящих данных. --- -## Макросы OUT (выходы нод) +## 2) Выходы нод (OUT) и ссылки на них -Доступ к выходам нод возможен в двух формах: +Во время исполнения пайплайна результаты предыдущих нод собираются в снапшот OUT и доступны при рендере шаблонов следующих нод: -### 1) Короткая форма (best‑effort текст) -- [[OUT1]] — «текст» из ноды n1 -- [[OUT2]] — из ноды n2 и т.д. +- OUT — словарь выходов нод, ключи — id нод в пайплайне (например, "n1", "n2"). + - OUT.n1, OUT.n2, ... — объект результата соответствующей ноды. -Что делает «best‑effort текст»: -- Если нода вернула response_text или text — берётся он -- Если нода вернула объект провайдера: - - OpenAI: choices[0].message.content - - Gemini: candidates[0].content.parts[0].text - - Claude: content[].text (склейка) -- Если ничего из выше не подошло — выполняется глубокий поиск текстовых полей ("text"/"content") +Формы доступа: +- Полная форма: [[OUT:n1.result.choices.0.message.content]] + (или фигурными скобками: {{ OUT.n1.result.choices.0.message.content }}) +- Короткая форма «просто текст»: [[OUT1]], [[OUT2]], ... + Это эвристика: берётся самое вероятное «текстовое» поле из результата (см. [_best_text_from_outputs()](agentui/pipeline/templating.py:121)). -Реализация: [_best_text_from_outputs()](agentui/pipeline/executor.py:45) и подстановка коротких OUT: [render_template_simple()](agentui/pipeline/executor.py:155) +Что возвращают встроенные ноды: +- ProviderCall: + - OUT.nX.result — сырой JSON ответа провайдера + - OUT.nX.response_text — уже извлечённый «лучший текст» (строка) +- RawForward: + - OUT.nX.result — JSON, как пришёл от апстрима (или {"error": "...", "text": "..."} при не‑JSON ответе) -### 2) Полная форма (точный путь) -- [[OUT:n1.result]] — целиком результат ноды n1 -- [[OUT:n1.result.candidates.0.content.parts.0.text]] — конкретный путь -- Эквивалент через фигурные скобки: {{ OUT.n1.result.candidates.0.content.parts.0.text }} - -Совет: используйте короткий [[OUTx]] если нужно «просто текст». Используйте полную форму, если нужен конкретный фрагмент/массив. +Подсказка по короткой форме [[OUTx]]: +- OpenAI: вернёт choices[0].message.content +- Gemini: вернёт candidates[0].content.parts[0].text +- Claude: склеит content[].text +- Если явных полей нет — выполнит «глубокий поиск» по ключам "text"/"content" --- -## Единый фрагмент [[PROMPT]] +## 3) Макросы подстановки и синтаксис -[[PROMPT]] — это уже собранный JSON‑фрагмент из ваших Prompt Blocks. Он зависит от выбранного провайдера ноды: -- OpenAI → "messages": [...] -- Gemini → "contents": [...], "systemInstruction": {...} -- Claude → "system": "...", "messages": [...] +В шаблонах доступны обе формы подстановки: -Как использовать внутри JSON‑шаблона: +1) Квадратные скобки [[ ... ]] — простая подстановка +- [[VAR:путь]] — взять значение из контекста по точечному пути + Пример: [[VAR:incoming.json.max_tokens]] +- [[OUT:путь]] — взять значение из OUT (см. раздел выше) + Пример: [[OUT:n1.result.choices.0.message.content]] +- [[OUT1]] / [[OUT2]] — короткая форма «просто текст» +- [[PROMPT]] — специальный JSON‑фрагмент из Prompt Blocks (см. ниже) -{ - "model": "{{ model }}", - [[PROMPT]], - "temperature": {{ params.temperature|default(0.7) }} -} - -Вы также можете использовать сырьевые структуры: -- {{ pm.messages }} -- {{ pm.contents }} -- {{ pm.systemInstruction }} -- {{ pm.system_text }} - -Но рекомендуемый путь — [[PROMPT]]: меньше шансов сломать JSON. +2) Фигурные скобки {{ ... }} — «джинджа‑лайт» +- {{ путь }} — взять значение по пути из контекста (или из OUT.* если начать с OUT.) + Пример: {{ OUT.n1.result }} +- Фильтр по умолчанию: {{ что-то|default(значение) }} + Примеры: + - {{ params.temperature|default(0.7) }} + - {{ incoming.json.stop|default([]) }} + - {{ anthropic_version|default('2023-06-01') }} — см. «Опциональные поля» ниже +- Фигурные скобки удобны там, где нужно вставить внутрь JSON не строку, а ЧИСЛО/ОБЪЕКТ/МАССИВ без кавычек и/или задать дефолт. --- -## Примеры по провайдерам +## 4) ProviderCall: Prompt Blocks, pm.* и [[PROMPT]] -### OpenAI (/v1/chat/completions) +Узел ProviderCall собирает ваши Prompt Blocks (блоки вида: роль/текст/вкл‑выкл/порядок) в стандартные «сообщения» и превращает их в структуру для конкретного провайдера. +Внутри шаблонов этого узла доступны: +- pm — «сырьевые» структуры из Prompt Blocks + - Для OpenAI: + - pm.messages — массив { role, content, name? } + - pm.system_text — один большой текст из всех system‑блоков + - Для Gemini: + - pm.contents — массив { role: "user"|"model", parts: [{text}] } + - pm.systemInstruction — объект вида { parts: [{text}] } или пустой {} + - pm.system_text — строка + - Для Claude: + - pm.system_text — строка + - pm.system — то же самое (удобно подставлять в поле "system") + - pm.messages — массив { role: "user"|"assistant", content: [{type:"text", text:"..."}] } + +- [[PROMPT]] — готовый JSON‑фрагмент на основе pm, безопасный для вставки внутрь шаблона: + - OpenAI → подставит: "messages": [...] + - Gemini → подставит: "contents": [...], "systemInstruction": {...} + - Claude → подставит: "system": "...", "messages": [...] + +Зачем это нужно? +- Чтобы 1) удобно собирать промпт из визуальных блоков, 2) не «сломать» JSON руками. + Вы можете вручную использовать {{ pm.* }}, но [[PROMPT]] — рекомендуемый и самый безопасный вариант. + +--- + +## 5) Частые сценарии и примеры + +Примеры ниже можно вклеивать в поле «template» ноды ProviderCall. Они уже используют [[PROMPT]] и аккуратные дефолты. + +OpenAI (POST /v1/chat/completions): +``` { "model": "{{ model }}", [[PROMPT]], @@ -98,9 +151,10 @@ "max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }}, "stop": {{ incoming.json.stop|default(params.stop|default([])) }} } +``` -### Gemini (/v1beta/models/{model}:generateContent?key=...) - +Gemini (POST /v1beta/models/{model}:generateContent): +``` { "model": "{{ model }}", [[PROMPT]], @@ -112,71 +166,163 @@ "stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }} } } +``` +Подсказка: ключ Gemini удобно брать из строки запроса: +в endpoint используйте …?key=[[VAR:incoming.api_keys.key]] -### Claude (/v1/messages) - +Claude (POST /v1/messages): +``` { "model": "{{ model }}", [[PROMPT]], "temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }}, "top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }}, - "max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }} + "max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }}, + "system": {{ pm.system|default("") }} } +``` +Подсказка: ключ Anthropic обычно передают в заголовке x-api-key. +В UI‑пресете это поле уже есть в headers. + +RawForward (прямой форвард входящего запроса): +- Поля конфигурации base_url, override_path, extra_headers проходят через те же макросы, поэтому можно подставлять динамику: + - base_url: https://generativelanguage.googleapis.com + - override_path: [[VAR:incoming.path]] (или задать свой) + - extra_headers (JSON): `{"X-Trace":"req-{{ incoming.query_params.session|default('no-session') }}"}` --- -## Частые кейсы +## 6) Опциональные/редкие поля, о которых стоит знать -1) Взять текст пользователя из входящего запроса и передать в Prompt Blocks -- Gemini: [[VAR:incoming.json.contents.0.parts.0.text]] -- OpenAI: [[VAR:incoming.json.messages.0.content]] -- Claude: [[VAR:incoming.json.messages.0.content.0.text]] +- anthropic_version — используется как HTTP‑заголовок для Claude ("anthropic-version"). В тело запроса не вставляется. + Если нужен дефолт, задавайте его в headers (например, в конфиге ноды/шаблоне заголовков). В шаблонах тела используйте [[PROMPT]]/pm.* без anthropic_version. -2) Переписать ответ предыдущей ноды «как текст» -- [[OUT1]] — если предыдущая нода имеет id n1 - -3) Добавить ключ Gemini из query в endpoint -- /v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]] +- stream — в MVP стриминг отключён, сервер принудительно не стримит ответ. + В шаблонах можно встретить поля stream, но по умолчанию они не включены. --- -## Почему местами нужны {{ ... }} +## 7) Когда использовать [[...]] и когда {{ ... }} -Внутри JSON нам важно вставлять объекты/массивы без кавычек и иметь дефолты: -- {{ pm.contents }} — вставит массив как массив -- {{ params.temperature|default(0.7) }} — если нет значения, подставится 0.7 +- Внутрь JSON как ОБЪЕКТ/МАССИВ/ЧИСЛО: используйте {{ ... }} + (фигурные скобки вставляют «как есть», без кавычек, и умеют |default(...)) +- Для строк/URL/заголовков/простых значений: можно использовать [[...]] + (квадратные скобки удобны и короче писать) -Квадратные [[...]] хорошо подходят для строк/простых значений и для URL/заголовков. +Примеры: +- {{ pm.contents }} — вставит массив как настоящий массив (без кавычек) +- {{ params.temperature|default(0.7) }} — безопасный дефолт для числа +- [[VAR:incoming.api_keys.authorization]] — быстро подставить строку Authorization --- -## Отладка +## 8) Отладка и рекомендации -- Проверьте лог DEBUG в консоли: ProviderCallNode показывает провайдера, URL и первые 400 символов тела запроса. +- ProviderCall печатает в консоль DEBUG сведения: выбранный провайдер, конечный URL, первые символы тела запроса — удобно для проверки корректности шаблона. - Если «ничего не подставилось»: - - убедитесь, что не подаёте входной payload в ProviderCall (иначе шаблон игнорируется); - - проверьте валидность JSON после подстановок; - - проверьте, что макрос написан корректно (OUT против OUTn). + 1) Проверьте, что вы НЕ передаёте сырое входное тело напрямую в ProviderCall (узел строит тело из шаблона и Prompt Blocks). + 2) Убедитесь, что итоговый JSON валиден (закрывающие скобки, запятые). + 3) Проверьте точность путей в макросах (OUT vs OUTx, правильные id нод n1/n2/...). +- Для ссылок на выходы предыдущих нод используйте [[OUT1]] как «просто текст», либо полные пути [[OUT:n1...]] для точного фрагмента. --- -## Мини‑FAQ +## 9) Быстрая памятка по ключам доступа -В: Почему [[OUT1]] пустой? -О: Возможно, нода n1 не вернула текстового поля, и глубокий поиск не нашёл текста. Уточните путь через полную форму [[OUT:n1....]]. - -В: Можно ли получить весь «сырой» ответ? -О: [[OUT:n1.result]] — вернёт весь JSON результата ноды n1. - -В: Почему фигурные скобки иногда обязательны? -О: Они умеют |default(...) и корректно вставляют объекты/массивы внутрь JSON. +- Gemini: [[VAR:incoming.api_keys.key]] — рекомендовано; ключ приходит в query (?key=...). +- OpenAI: [[VAR:incoming.headers.authorization]] (или [[VAR:incoming.api_keys.authorization]]) — стандартный Bearer‑токен. +- Anthropic: [[VAR:incoming.headers.x-api-key]] — ключ в заголовке. --- -## Ссылки на реализацию +## 10) Ссылки на реализацию (для интересующихся деталями) -- Макросы/рендер: [render_template_simple()](agentui/pipeline/executor.py:125) -- Единый [[PROMPT]]: [ProviderCallNode.run()](agentui/pipeline/executor.py:604) -- Короткий [[OUTx]] и извлечение текста: [render_template_simple()](agentui/pipeline/executor.py:155), [_best_text_from_outputs()](agentui/pipeline/executor.py:45) +- Контекст (переменные): [build_macro_context()](agentui/api/server.py:142) +- Исполнение пайплайна, зависимости, снапшоты OUT: [PipelineExecutor.run()](agentui/pipeline/executor.py:136) +- Узел провайдера (Prompt Blocks → провайдер): [ProviderCallNode.run()](agentui/pipeline/executor.py:650) +- PM‑структуры для шаблонов: [ProviderCallNode._blocks_struct_for_template()](agentui/pipeline/executor.py:592) +- Подстановка [[PROMPT]], макросы, дефолты: [render_template_simple()](agentui/pipeline/templating.py:187) +- Короткая форма [[OUTx]] и поиск «лучшего текста»: [_best_text_from_outputs()](agentui/pipeline/templating.py:124) +- Прямой форвард входящего запроса: [RawForwardNode.run()](agentui/pipeline/executor.py:833) +- Детекция вендора по входному payload: [detect_vendor()](agentui/common/vendors.py:8) -Удачного редактирования! \ No newline at end of file +Удачного редактирования! +--- +## Пользовательские переменные (SetVars) — «для людей» + +Задача: в начале пайплайна положить свои значения и потом использовать их в шаблонах одной строкой — например [[MY_KEY]] или {{ MAX_TOKENS }}. + +Где это в UI +- В левой панели добавьте ноду SetVars и откройте её в инспекторе. +- Жмите «Добавить переменную», у каждой переменной есть три поля: + - name — имя переменной (латинские буквы/цифры/подчёркивание, не с цифры): MY_KEY, REGION, MAX_TOKENS + - mode — режим обработки значения: + - string — строка, в которой работают макросы ([[...]] и {{ ... }}) + - expr — «мини‑формула» без макросов (подробнее ниже) + - value — собственно значение + +Как потом вставлять переменные +- Для строк (URL/заголовки/текст) — квадратные скобки: [[MY_KEY]] +- Для чисел/массивов/объектов — фигурные скобки: {{ MAX_TOKENS }}, {{ GEN_CFG }} + +Примеры «как надо» +- Переменная-строка (mode=string): + - name: AUTH + - value: "Bearer [[VAR:incoming.headers.authorization]]" + - Использование в заголовке: "Authorization": "[[AUTH]]" +- Переменная-число (mode=expr): + - name: MAX_TOKENS + - value: 128 + 64 + - Использование в JSON: "max_tokens": {{ MAX_TOKENS }} +- Переменная-объект (mode=expr): + - name: GEN_CFG + - value: {"temperature": 0.3, "topP": 0.9, "safe": true} + - Использование: "generationConfig": {{ GEN_CFG }} + +Важно про два режима +- string — это «шаблон». Внутри работают все макросы ([[VAR:...]], [[OUT:...]], [[PROMPT]], {{ ... }}). Значение прогоняется через рендер [render_template_simple()](agentui/pipeline/templating.py:184). +- expr — это «мини‑формула». Внутри НЕТ макросов и НЕТ доступа к контексту; только литералы и операции (см. ниже). Вычисляет значение безопасно — без eval, на белом списке AST (реализация: [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291)). + +Что умеет expr (мини‑формулы) +- Числа и арифметика: 128 + 64, (5 * 60) + 30, 42 % 2, -5, 23 // 10 +- Строки: "eu" + "-central" → "eu-central" (строки склеиваем знаком +) +- Булева логика: (2 < 3) and (10 % 2 == 0), 1 < 2 < 5 +- Коллекции: ["fast", "safe"], {"temperature": 0.3, "topP": 0.9, "safe": true} +- JSON‑литералы: true/false/null, объекты и массивы — если выражение является чистым JSON, оно разбирается напрямую (без макросов), т.е. true→True, null→None и т.п. +- Запрещено: функции (кроме специально разрешённых ниже), доступ к переменным/контексту, атрибуты/индексация/условные выражения. + +Рандом в expr +- В expr доступны три простые функции случайности: + - rand() → число с плавающей точкой в диапазоне [0, 1) + - randint(a, b) → целое число от a до b включительно + - choice(list) → случайный элемент из списка/кортежа +- Примеры: + - name: RAND_F, mode: expr, value: rand() + - "temperature": {{ RAND_F }} + - name: DICE, mode: expr, value: randint(1, 6) + - "dice_roll": {{ DICE }} + - name: PICK_MODEL, mode: expr, value: choice(["gpt-4o-mini", "gpt-4o", "o3-mini"]) + - "model": "[[PICK_MODEL]]" +- Зерна/seed нет — каждый запуск выдаёт новое значение. + +«Почему в expr нельзя подставлять переменные/макросы?» +- Для безопасности и предсказуемости: expr — это закрытый мини‑язык без окружения. +- Если нужно использовать другие переменные/макросы — делайте это в режиме string (там всё рендерится шаблонизатором). +- Технические детали: защита реализована в [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291), а вставка string‑значений — через [render_template_simple()](agentui/pipeline/templating.py:184). + +Как это работает внутри (если интересно) +- SetVars исполняется как обычная нода пайплайна и отдаёт {"vars": {...}}. +- Исполнитель добавляет эти значения в контекст для последующих нод как context.vars (см. [PipelineExecutor.run()](agentui/pipeline/executor.py:131)). +- При рендере шаблонов: + - [[NAME]] и {{ NAME }} подставляются с приоритетом из пользовательских переменных (см. обработку в [render_template_simple()](agentui/pipeline/templating.py:184)). +- Сам SetVars считает переменные в порядке списка и возвращает их одним пакетом (внутри одной ноды значения не зависят друг от друга). + +Частые вопросы +- «Хочу собрать строку с частями из внешнего запроса»: делайте mode=string и пишите: "Bearer [[VAR:incoming.headers.authorization]]". +- «Хочу массив случайных чисел»: mode=expr → [rand(), rand(), rand()], а в JSON: "numbers": {{ MY_LIST }} +- «Почему мои значения не сохраняются?» — нажмите «Сохранить параметры» в инспекторе ноды, затем «Сохранить пайплайн» в шапке. UI синхронизирует данные в node.data и сохраняет в pipeline.json (см. [static/editor.html](static/editor.html)). + +Ссылки на реализацию (для любопытных) +- Нода переменных: [SetVarsNode](agentui/pipeline/executor.py:264), [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291), [SetVarsNode.run()](agentui/pipeline/executor.py:354) +- Исполнитель/контекст vars: [PipelineExecutor.run()](agentui/pipeline/executor.py:131) +- Шаблоны и макросы (включая «голые» [[NAME]]/{{ NAME }}): [render_template_simple()](agentui/pipeline/templating.py:184) \ No newline at end of file diff --git a/run_agentui.bat b/run_agentui.bat index a2ae104..7fcb524 100644 --- a/run_agentui.bat +++ b/run_agentui.bat @@ -8,7 +8,8 @@ if errorlevel 1 goto :fail pip install -r requirements.txt if errorlevel 1 goto :fail echo Starting НадTavern on http://127.0.0.1:%PORT%/ -start "НадTavern UI" python -c "import time,webbrowser,os; time.sleep(1); webbrowser.open('http://127.0.0.1:%s/ui/editor.html'%os.environ.get('PORT','7860'))" +timeout /t 1 /nobreak >NUL +start "" "http://127.0.0.1:%PORT%/ui/editor.html" python -m uvicorn agentui.api.server:app --host 127.0.0.1 --port %PORT% --log-level info if errorlevel 1 goto :fail goto :end diff --git a/static/editor.css b/static/editor.css index 967dc25..a454f8e 100644 --- a/static/editor.css +++ b/static/editor.css @@ -301,4 +301,27 @@ button:hover { background: #273246; } #drawflow .drawflow-delete:active, .drawflow-delete:active { transform: translate(-50%, -50%) scale(0.97) !important; +} +/* Execution highlight states (SSE-driven) */ +.drawflow .drawflow-node .title-box, +.drawflow .drawflow-node .box { + transition: border-color .12s ease, box-shadow .12s ease, background-color .12s ease; +} + +.drawflow .drawflow-node.node-running .title-box, +.drawflow .drawflow-node.node-running .box { + border-color: #60a5fa !important; /* blue */ + box-shadow: 0 0 0 2px rgba(96,165,250,.35) !important; +} + +.drawflow .drawflow-node.node-ok .title-box, +.drawflow .drawflow-node.node-ok .box { + border-color: #34d399 !important; /* green */ + box-shadow: 0 0 0 2px rgba(52,211,153,.35) !important; +} + +.drawflow .drawflow-node.node-err .title-box, +.drawflow .drawflow-node.node-err .box { + border-color: #ef4444 !important; /* red */ + box-shadow: 0 0 0 2px rgba(239,68,68,.35) !important; } \ No newline at end of file diff --git a/static/editor.html b/static/editor.html index 9c46926..0b04614 100644 --- a/static/editor.html +++ b/static/editor.html @@ -54,7 +54,7 @@ details.help summary::-webkit-details-marker { display: none; } details.help .panel { margin-top: 8px; background: #0f141a; border: 1px solid #2b3646; padding: 10px; border-radius: 8px; } - +
@@ -74,8 +74,10 @@
Ключи (API Keys): [[incoming.api_keys.authorization]], - [[incoming.api_keys.key]], - [[incoming.api_keys.secret]] + [[incoming.api_keys.key]]
Быстрые макросы: [[PROMPT]], [[OUT1]], - [[OUT2]] + [[OUT2]], + [[NAME]] | Расширенно: [[OUT:n1.result...]] или {{ OUT.n1.result... }}
Отладка
@@ -127,17 +129,24 @@ + + + + - - - + \ No newline at end of file diff --git a/static/js/pm-ui.js b/static/js/pm-ui.js new file mode 100644 index 0000000..84f5cf7 --- /dev/null +++ b/static/js/pm-ui.js @@ -0,0 +1,191 @@ +/* global window, document */ +// AgentUI Prompt Manager UI extracted from editor.html +// Exposes window.PM.setupProviderCallPMUI(editor, id) +// Depends on DOM elements rendered by editor.html inspector: +// #pm-list, #pm-editor, #pm-name, #pm-role, #pm-prompt, #pm-save, #pm-cancel +(function (w) { + 'use strict'; + + function setupProviderCallPMUI(editor, id) { + try { + const n2 = editor.getNodeFromId(id); + if (!n2) return; + const d2 = n2.data || {}; + if (!Array.isArray(d2.blocks)) d2.blocks = []; + + // Ensure node.data and DOM __data always reflect latest blocks + function syncNodeDataBlocks() { + try { + const n = editor.getNodeFromId(id); + if (!n) return; + // Готовим новые данные с глубокой копией blocks + const newData = { ...(n.data || {}), blocks: Array.isArray(d2.blocks) ? d2.blocks.map(b => ({ ...b })) : [] }; + // 1) Обновляем внутреннее состояние Drawflow, чтобы export() возвращал актуальные данные + try { editor.updateNodeDataFromId(id, newData); } catch (e) {} + // 2) Обновляем DOM-отражение (источник правды для toPipelineJSON) + const el2 = document.querySelector(`#node-${id}`); + if (el2) el2.__data = JSON.parse(JSON.stringify(newData)); + } catch (e) {} + } + // Initial sync to attach blocks into __data for toPipelineJSON + syncNodeDataBlocks(); + + const listEl = document.getElementById('pm-list'); + const addBtn = document.getElementById('pm-add'); + const editorBox = document.getElementById('pm-editor'); + const nameInp = document.getElementById('pm-name'); + const roleSel = document.getElementById('pm-role'); + const promptTxt = document.getElementById('pm-prompt'); + const saveBtn = document.getElementById('pm-save'); + const cancelBtn = document.getElementById('pm-cancel'); + let editingId = null; + + // Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока. + + // Drag&Drop через SortableJS (если доступен) + if (w.Sortable && listEl && !listEl.__sortable) { + listEl.__sortable = new w.Sortable(listEl, { + animation: 150, + handle: '.pm-handle', + onEnd(evt) { + const oldIndex = evt.oldIndex; + const newIndex = evt.newIndex; + if (oldIndex === newIndex) return; + const moved = d2.blocks.splice(oldIndex, 1)[0]; + d2.blocks.splice(newIndex, 0, moved); + d2.blocks.forEach((b, i) => b.order = i); + syncNodeDataBlocks(); + } + }); + } + + function sortAndReindex() { + d2.blocks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + d2.blocks.forEach((b, i) => b.order = i); + } + + function renderList() { + sortAndReindex(); + if (!listEl) return; + listEl.innerHTML = ''; + d2.blocks.forEach((b, i) => { + const domId = b.id || ('b' + i); + const li = document.createElement('li'); + li.draggable = true; + li.dataset.id = domId; + li.style.display = 'flex'; + li.style.alignItems = 'center'; + li.style.gap = '6px'; + li.style.padding = '4px 0'; + li.innerHTML = ` + + + ${(b.name || ('Block ' + (i + 1))).replace(/ + ${b.role || 'user'} + + + `; + // DnD via HTML5 fallback as well (kept for compatibility) + li.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', domId); }); + li.addEventListener('dragover', e => { e.preventDefault(); }); + li.addEventListener('drop', e => { + e.preventDefault(); + const srcId = e.dataTransfer.getData('text/plain'); + const tgtId = domId; + if (!srcId || srcId === tgtId) return; + const srcIdx = d2.blocks.findIndex(x => (x.id || '') === srcId); + const tgtIdx = d2.blocks.findIndex(x => (x.id || '') === tgtId); + if (srcIdx < 0 || tgtIdx < 0) return; + const [moved] = d2.blocks.splice(srcIdx, 1); + d2.blocks.splice(tgtIdx, 0, moved); + sortAndReindex(); + renderList(); + syncNodeDataBlocks(); + }); + // toggle + li.querySelector('.pm-enabled').addEventListener('change', ev => { + b.enabled = ev.target.checked; + syncNodeDataBlocks(); + }); + // edit + li.querySelector('.pm-edit').addEventListener('click', () => { + openEditor(b); + }); + // delete + li.querySelector('.pm-del').addEventListener('click', () => { + const idx = d2.blocks.indexOf(b); + if (idx >= 0) d2.blocks.splice(idx, 1); + sortAndReindex(); + renderList(); + syncNodeDataBlocks(); + if (editingId && editingId === (b.id || null)) { + if (editorBox) editorBox.style.display = 'none'; + editingId = null; + } + }); + listEl.appendChild(li); + }); + } + + function openEditor(b) { + // Гарантируем наличие id у редактируемого блока + if (!b.id) { + b.id = 'b' + Date.now().toString(36); + syncNodeDataBlocks(); + } + editingId = b.id; + if (editorBox) editorBox.style.display = ''; + if (nameInp) nameInp.value = b.name || ''; + if (roleSel) roleSel.value = (b.role || 'user'); + if (promptTxt) promptTxt.value = b.prompt || ''; + } + + if (addBtn) { + addBtn.addEventListener('click', () => { + const idv = 'b' + Date.now().toString(36); + const nb = { id: idv, name: 'New Block', role: 'system', prompt: '', enabled: true, order: d2.blocks.length }; + d2.blocks.push(nb); + sortAndReindex(); + renderList(); + syncNodeDataBlocks(); + openEditor(nb); + }); + } + + if (saveBtn) { + saveBtn.addEventListener('click', () => { + if (!editingId) { if (editorBox) editorBox.style.display = 'none'; return; } + const b = d2.blocks.find(x => (x.id || null) === editingId); + if (b) { + b.name = nameInp ? nameInp.value : b.name; + b.role = roleSel ? roleSel.value : b.role || 'user'; + b.prompt = promptTxt ? promptTxt.value : b.prompt; + // Пересоберём массив, чтобы избежать проблем с мутацией по ссылке + d2.blocks = d2.blocks.map(x => (x.id === b.id ? ({ ...b }) : x)); + } + if (editorBox) editorBox.style.display = 'none'; + editingId = null; + renderList(); + syncNodeDataBlocks(); + // попытка автосохранения пайплайна, если доступна глобальная функция + try { (typeof w.savePipeline === 'function') && w.savePipeline(); } catch (e) {} + try { (typeof w.status === 'function') && w.status('Блок сохранён в pipeline.json'); } catch (e) {} + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + if (editorBox) editorBox.style.display = 'none'; + editingId = null; + }); + } + + // Первичная отрисовка + renderList(); + } catch (e) {} + } + + w.PM = { + setupProviderCallPMUI + }; +})(window); \ No newline at end of file diff --git a/static/js/serialization.js b/static/js/serialization.js new file mode 100644 index 0000000..43f4ba5 --- /dev/null +++ b/static/js/serialization.js @@ -0,0 +1,247 @@ +/* global window, document, requestAnimationFrame */ +// AgentUI serialization helpers extracted from editor.html +// Exposes window.AgentUISer.{toPipelineJSON, fromPipelineJSON} +// Depends on globals defined by editor.html: editor, NODE_IO, addNode, applyNodeDefaults, status +(function (w) { + 'use strict'; + + function ensureDeps() { + if (!w || !w.editor) throw new Error('AgentUISer: global editor is not available'); + if (!w.NODE_IO) throw new Error('AgentUISer: global NODE_IO is not available'); + } + + // Drawflow -> pipeline JSON + function toPipelineJSON() { + ensureDeps(); + const editor = w.editor; + const NODE_IO = w.NODE_IO; + + const data = editor.export(); + const nodes = []; + const idMap = {}; // drawflow id -> generated id like n1, n2 + + const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {}; + + // 1) Собираем ноды + let idx = 1; + for (const id in dfNodes) { + const df = dfNodes[id]; + const genId = `n${idx++}`; + idMap[id] = genId; + const el = document.querySelector(`#node-${id}`); + // Берём источник правды из DOM.__data (куда жмём «Сохранить параметры») или из drawflow.data + const datacopySrc = el && el.__data ? el.__data : (df.data || {}); + const datacopy = typeof w.applyNodeDefaults === 'function' + ? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc))) + : (JSON.parse(JSON.stringify(datacopySrc))); + nodes.push({ + id: genId, + type: df.name, + pos_x: df.pos_x, + pos_y: df.pos_y, + config: datacopy, + in: {} + }); + } + + // 2) Восстанавливаем связи по входам (inputs) + // В Drawflow v0.0.55 inputs/outputs — это объекты вида input_1/output_1 + for (const id in dfNodes) { + const df = dfNodes[id]; + const targetNode = nodes.find(n => n.id === idMap[id]); + if (!targetNode) continue; + const io = NODE_IO[targetNode.type] || { inputs: [], outputs: [] }; + + for (let i = 0; i < io.inputs.length; i++) { + const inputKey = `input_${i + 1}`; + const input = df.inputs && df.inputs[inputKey]; + if (!input || !Array.isArray(input.connections) || input.connections.length === 0) continue; + + // Собираем все связи этого входа и сохраняем строку либо массив строк (для depends поддерживаем мульти-коннекты) + const refs = []; + for (const conn of (input.connections || [])) { + if (!conn) continue; + const sourceDfId = String(conn.node); + const outKey = String(conn.output ?? ''); + + // conn.output может быть "output_1", "1" (строкой), либо числом 1 + let sourceOutIdx = -1; + let m = outKey.match(/output_(\d+)/); + if (m) { + sourceOutIdx = parseInt(m[1], 10) - 1; + } else if (/^\d+$/.test(outKey)) { + sourceOutIdx = parseInt(outKey, 10) - 1; + } else if (typeof conn.output === 'number') { + sourceOutIdx = conn.output - 1; + } + if (!(sourceOutIdx >= 0)) sourceOutIdx = 0; // safety + + const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]); + if (!sourceNode) continue; + const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] }; + const sourceOutName = (sourceIo.outputs && sourceIo.outputs[sourceOutIdx] != null) + ? sourceIo.outputs[sourceOutIdx] + : `out${sourceOutIdx}`; + refs.push(`${sourceNode.id}.${sourceOutName}`); + } + + // Каноничное имя входа: по NODE_IO, иначе in{0-based} + const targetInName = (io.inputs && io.inputs[i] != null) + ? io.inputs[i] + : `in${i}`; + + if (!targetNode.in) targetNode.in = {}; + targetNode.in[targetInName] = (refs.length <= 1 ? refs[0] : refs); + } + } + + return { id: 'pipeline_editor', name: 'Edited Pipeline', nodes }; + } + + // pipeline JSON -> Drawflow + async function fromPipelineJSON(p) { + ensureDeps(); + const editor = w.editor; + const NODE_IO = w.NODE_IO; + + editor.clear(); + let x = 100; let y = 120; // Fallback + const idMap = {}; // pipeline id -> drawflow id + const logs = []; + const $ = (sel) => document.querySelector(sel); + + const resolveOutIdx = (type, outName) => { + const outs = ((NODE_IO[type] && NODE_IO[type].outputs) || []); + let idx = outs.indexOf(outName); + if (idx < 0 && typeof outName === 'string') { + // поддержка: out-1, out_1, output_1, out1, out0 + const s = String(outName); + let m = s.match(/^out(?:put)?[_-]?(\d+)$/); + if (m) { + const n = parseInt(m[1], 10); + idx = n > 0 ? n - 1 : 0; + } else { + m = s.match(/^out(\d+)$/); // совместимость со старым out0 + if (m) idx = parseInt(m[1], 10) | 0; + } + } + return idx; + }; + const resolveInIdx = (type, inName) => { + const ins = ((NODE_IO[type] && NODE_IO[type].inputs) || []); + let idx = ins.indexOf(inName); + if (idx < 0 && typeof inName === 'string') { + // поддержка: in-1, in_1, in1, in0 + const s = String(inName); + let m = s.match(/^in[_-]?(\d+)$/); + if (m) { + const n = parseInt(m[1], 10); + idx = n > 0 ? n - 1 : 0; + } else { + m = s.match(/^in(\d+)$/); // совместимость со старым in0 + if (m) idx = parseInt(m[1], 10) | 0; + } + } + return idx; + }; + + // Ожидание появления порта в DOM (устранение гонки рендера) + async function waitForPort(dfid, kind, idx, tries = 60, delay = 16) { + // Drawflow создаёт DOM-узел с id="node-${dfid}" + const sel = `#node-${dfid} .${kind}_${idx}`; + for (let i = 0; i < tries; i++) { + if ($(sel)) return true; + await new Promise(r => setTimeout(r, delay)); + } + logs.push(`port missing: #${dfid} ${kind}_${idx}`); + return false; + } + // Повторные попытки соединить порты, пока DOM не готов + async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, delay = 25) { + const outClass = `output_${outNum}`; + const inClass = `input_${inNum}`; + for (let i = 0; i < tries; i++) { + const okOut = await waitForPort(srcDfId, 'output', outNum, 1, delay); + const okIn = await waitForPort(tgtDfId, 'input', inNum, 1, delay); + if (okOut && okIn) { + try { + editor.addConnection(srcDfId, tgtDfId, outClass, inClass); + return true; + } catch (e) { + // retry on next loop + } + } + await new Promise(r => setTimeout(r, delay)); + } + return false; + } + + // 1) Создаём ноды + for (const n of p.nodes) { + const pos = { x: n.pos_x || x, y: n.pos_y || y }; + const data = { ...(n.config || {}), _origId: n.id }; + const dfid = typeof w.addNode === 'function' + ? w.addNode(n.type, pos, data) + : (function () { throw new Error('AgentUISer: addNode() is not defined'); })(); + idMap[n.id] = dfid; + if (!n.pos_x) x += 260; // раскладываем по горизонтали, если нет сохраненной позиции + } + + // 2) Дождёмся полного рендера DOM + await new Promise(r => setTimeout(r, 0)); + if (typeof requestAnimationFrame === 'function') { + await new Promise(r => requestAnimationFrame(() => r())); + await new Promise(r => requestAnimationFrame(() => r())); // двойной rAF для надежности + } else { + await new Promise(r => setTimeout(r, 32)); + } + // 3) Проставляем связи из in (поддержка строк и массивов ссылок) + for (const n of p.nodes) { + if (!n.in) continue; + const targetDfId = idMap[n.id]; + const targetIo = NODE_IO[n.type] || { inputs: [] }; + for (const [inName, ref] of Object.entries(n.in)) { + const refs = Array.isArray(ref) ? ref : [ref]; + for (const oneRef of refs) { + if (!oneRef || typeof oneRef !== 'string' || !oneRef.includes('.')) continue; + const [srcId, outName] = oneRef.split('.'); + const sourceDfId = idMap[srcId]; + if (!sourceDfId) { logs.push(`skip: src ${srcId} not found`); continue; } + const srcType = p.nodes.find(nn => nn.id === srcId)?.type; + + let outIdx = resolveOutIdx(srcType, outName); + let inIdx = resolveInIdx(n.type, inName); + + // Fallback на первый порт, если неизвестные имена, но порт существует + if (outIdx < 0) outIdx = 0; + if (inIdx < 0) inIdx = 0; + + const ok = await connectWithRetry(sourceDfId, targetDfId, outIdx + 1, inIdx + 1, 200, 25); + if (ok) { + logs.push(`connect: ${srcId}.${outName} (#${sourceDfId}.output_${outIdx + 1}) -> ${n.id}.${inName} (#${targetDfId}.input_${inIdx + 1})`); + } else { + logs.push(`skip connect (ports not ready after retries): ${srcId}.${outName} -> ${n.id}.${inName}`); + } + } + } + } + + // 4) Обновим линии и выведем лог + try { + Object.values(idMap).forEach((dfid) => { + if (editor.updateConnectionNodes) { + editor.updateConnectionNodes(`node-${dfid}`); + } + }); + } catch {} + if (logs.length) { + try { (typeof w.status === 'function') && w.status('Загружено (links):\n' + logs.join('\n')); } catch {} + try { console.debug('[fromPipelineJSON]', logs); } catch {} + } + } + + w.AgentUISer = { + toPipelineJSON, + fromPipelineJSON, + }; +})(window); \ No newline at end of file diff --git a/tests/test_templating.py b/tests/test_templating.py new file mode 100644 index 0000000..7ebd842 --- /dev/null +++ b/tests/test_templating.py @@ -0,0 +1,87 @@ +from agentui.pipeline.templating import ( + render_template_simple, + _best_text_from_outputs, +) + +def run_checks(): + # Common context for tests + ctx = { + "model": "gpt-x", + "params": {"max_tokens": 128}, + "chat": {"last_user": "Привет"}, + "OUT": {}, + } + out_map = {} + + # 1) [[VAR:...]] + s1 = render_template_simple("Hello [[VAR:chat.last_user]]", ctx, out_map) + assert s1 == "Hello Привет" + + # 2) {{ ... |default(...) }} when missing + s2 = render_template_simple("T={{ params.temperature|default(0.7) }}", ctx, out_map) + assert s2 == "T=0.7" + # present + ctx2 = {**ctx, "params": {**ctx["params"], "temperature": 0.4}} + s3 = render_template_simple("T={{ params.temperature|default(0.7) }}", ctx2, out_map) + assert s3 == "T=0.4" + + # 3) [[OUT:n1...]] exact path + out_map = { + "n1": { + "result": { + "choices": [ + {"message": {"content": "Hi from OpenAI"}} + ] + } + } + } + s4 = render_template_simple("[[OUT:n1.result.choices.0.message.content]]", ctx, out_map) + assert s4 == "Hi from OpenAI" + + # 4) [[OUT1]] short form -> best-effort text + s5 = render_template_simple("[[OUT1]]", ctx, out_map) + assert s5 == "Hi from OpenAI" + + # 5) [[PROMPT]] raw fragment insertion + ctx_prompt = {**ctx, "PROMPT": '"messages": [{"role":"user","content":"Hi"}]'} + s6 = render_template_simple('{"model":"{{ model }}", [[PROMPT]] }', ctx_prompt, out_map) + # ensure prompt fragment is inserted without extra quotes + assert '"messages": [{"role":"user","content":"Hi"}]' in s6 + assert '"model":"gpt-x"' in s6 + + # 6) _best_text_from_outputs (Gemini) + gemini_out = { + "result": { + "candidates": [ + {"content": {"role": "model", "parts": [{"text": "Gemini says"}]}} + ] + } + } + assert _best_text_from_outputs(gemini_out) == "Gemini says" + + # 7) _best_text_from_outputs (Claude) + claude_out = { + "result": { + "content": [ + {"type": "text", "text": "Claude part 1"}, + {"type": "text", "text": "Claude part 2"}, + ] + } + } + assert _best_text_from_outputs(claude_out) == "Claude part 1\nClaude part 2" + + # 8) _best_text_from_outputs (direct response_text) + direct = {"response_text": "Direct text"} + assert _best_text_from_outputs(direct) == "Direct text" + + # 9) Mixed braces with OUT + out_map = { + "n2": {"result": {"obj": {"value": 42}}}, + } + s7 = render_template_simple("V={{ OUT.n2.result.obj.value|default(0) }}", ctx, out_map) + assert s7 == "V=42" + + +if __name__ == "__main__": + run_checks() + print("Templating tests: OK") \ No newline at end of file