sync: mnogo
This commit is contained in:
@@ -10,6 +10,7 @@ import hashlib
|
||||
from collections import deque
|
||||
from agentui.providers.http_client import build_client
|
||||
from agentui.common.vendors import detect_vendor
|
||||
from agentui.providers.adapters.registry import get_adapter, default_base_url_for as _adapter_default_base_url_for
|
||||
from agentui.pipeline.templating import (
|
||||
_OUT_MACRO_RE,
|
||||
_VAR_MACRO_RE,
|
||||
@@ -25,7 +26,45 @@ from agentui.pipeline.templating import (
|
||||
eval_condition_expr,
|
||||
)
|
||||
from agentui.pipeline.storage import load_var_store, save_var_store, clear_var_store
|
||||
from agentui.common.cancel import is_cancelled, clear_cancel
|
||||
from agentui.common.cancel import is_cancelled, clear_cancel, get_cancel_mode
|
||||
|
||||
# HTTP request registry for manual resend feature (store original untrimmed bodies)
|
||||
from collections import OrderedDict
|
||||
|
||||
_HTTP_REQ_REGISTRY_MAX = 200
|
||||
_HTTP_REQ_REGISTRY = OrderedDict()
|
||||
|
||||
def register_http_request(req_id: str, info: Dict[str, Any]) -> None:
|
||||
try:
|
||||
rid = str(req_id or "")
|
||||
if not rid:
|
||||
return
|
||||
# overwrite if exists to refresh order
|
||||
if rid in _HTTP_REQ_REGISTRY:
|
||||
try:
|
||||
_HTTP_REQ_REGISTRY.pop(rid, None)
|
||||
except Exception:
|
||||
pass
|
||||
_HTTP_REQ_REGISTRY[rid] = info or {}
|
||||
# trim oldest beyond cap
|
||||
while len(_HTTP_REQ_REGISTRY) > _HTTP_REQ_REGISTRY_MAX:
|
||||
try:
|
||||
_HTTP_REQ_REGISTRY.popitem(last=False)
|
||||
except Exception:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_http_request(req_id: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
rid = str(req_id or "")
|
||||
if not rid:
|
||||
return None
|
||||
v = _HTTP_REQ_REGISTRY.get(rid)
|
||||
# return shallow copy to avoid mutation
|
||||
return dict(v) if isinstance(v, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# --- Global helpers: robust auto-decompression for logging/JSON parsing ----------
|
||||
import gzip
|
||||
@@ -115,6 +154,54 @@ def _safe_response_json(resp) -> Any:
|
||||
return {"error": "Failed to decode JSON from upstream", "text": t}
|
||||
|
||||
|
||||
# --- Cooperative cancel/abort helper for in-flight awaits (HTTP, etc.) ---------
|
||||
async def _await_coro_with_cancel(coro: Awaitable[Any], pipeline_id: str, poll_interval: float = 0.1) -> Any:
|
||||
"""
|
||||
Await 'coro' while polling manual cancel flag for the pipeline.
|
||||
|
||||
Behavior:
|
||||
- If cancel mode is 'graceful' -> do NOT interrupt 'coro'; we just continue waiting
|
||||
and upper layers will stop before scheduling new work.
|
||||
- If cancel mode is 'abort' -> cancel in-flight task immediately and raise ExecutionError.
|
||||
|
||||
Returns the result of 'coro' or raises ExecutionError on abort.
|
||||
"""
|
||||
task = asyncio.create_task(coro)
|
||||
while True:
|
||||
try:
|
||||
# Shield from timeout cancellation; we only use timeout to poll flags.
|
||||
return await asyncio.wait_for(asyncio.shield(task), timeout=poll_interval)
|
||||
except asyncio.TimeoutError:
|
||||
# Not done yet — fall through to polling
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
# Map in-flight cancellation to ExecutionError on abort; otherwise re-raise
|
||||
try:
|
||||
mode = get_cancel_mode(pipeline_id)
|
||||
except Exception:
|
||||
mode = "abort"
|
||||
if mode == "abort":
|
||||
raise ExecutionError("Cancelled by user (abort)")
|
||||
raise
|
||||
# Poll cancel flag
|
||||
try:
|
||||
if is_cancelled(pipeline_id):
|
||||
mode = get_cancel_mode(pipeline_id)
|
||||
if mode == "abort":
|
||||
try:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
raise ExecutionError("Cancelled by user (abort)")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ExecutionError(f"Cancelled by user (abort): {exc}")
|
||||
# graceful: do not interrupt; keep waiting
|
||||
except Exception:
|
||||
# Be defensive: ignore polling failures and keep waiting
|
||||
pass
|
||||
|
||||
|
||||
# --- Helpers: sanitize base64/data URLs in JSON for logging (Burp-like) ----------
|
||||
def _is_b64ish_string(s: str) -> bool:
|
||||
try:
|
||||
@@ -442,7 +529,14 @@ class PipelineExecutor:
|
||||
# Исполнение
|
||||
try:
|
||||
out = await node.run(inputs, ctx)
|
||||
except Exception as exc:
|
||||
except BaseException as exc:
|
||||
err = exc
|
||||
try:
|
||||
import asyncio as _asyncio
|
||||
if isinstance(exc, _asyncio.CancelledError):
|
||||
err = ExecutionError("Cancelled by user (abort)")
|
||||
except Exception:
|
||||
pass
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({
|
||||
@@ -451,11 +545,11 @@ class PipelineExecutor:
|
||||
"node_type": node.type_name,
|
||||
"wave": wave_num,
|
||||
"ts": int(time.time() * 1000),
|
||||
"error": str(exc),
|
||||
"error": str(err),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
raise err
|
||||
else:
|
||||
dur_ms = int((time.perf_counter() - started) * 1000)
|
||||
if trace is not None:
|
||||
@@ -2053,15 +2147,26 @@ class ProviderCallNode(Node):
|
||||
|
||||
# Default endpoints if not set
|
||||
if not endpoint_tmpl:
|
||||
if provider == "openai":
|
||||
endpoint_tmpl = "/v1/chat/completions"
|
||||
elif provider == "gemini":
|
||||
endpoint_tmpl = "/v1beta/models/{{ model }}:generateContent"
|
||||
elif provider == "gemini_image":
|
||||
# Специальный провайдер для генерации/превью изображений Gemini (generateContent)
|
||||
endpoint_tmpl = "/v1beta/models/{{ model }}:generateContent"
|
||||
elif provider == "claude":
|
||||
endpoint_tmpl = "/v1/messages"
|
||||
_ad = None
|
||||
try:
|
||||
_ad = get_adapter(provider)
|
||||
except Exception:
|
||||
_ad = None
|
||||
if _ad is not None:
|
||||
try:
|
||||
endpoint_tmpl = _ad.default_endpoint(str(context.get("model") or ""))
|
||||
except Exception:
|
||||
endpoint_tmpl = ""
|
||||
if not endpoint_tmpl:
|
||||
if provider == "openai":
|
||||
endpoint_tmpl = "/v1/chat/completions"
|
||||
elif provider == "gemini":
|
||||
endpoint_tmpl = "/v1beta/models/{{ model }}:generateContent"
|
||||
elif provider == "gemini_image":
|
||||
# Специальный провайдер для генерации/превью изображений Gemini (generateContent)
|
||||
endpoint_tmpl = "/v1beta/models/{{ model }}:generateContent"
|
||||
elif provider == "claude":
|
||||
endpoint_tmpl = "/v1/messages"
|
||||
|
||||
# Default template for gemini_image if none provided (inject [[PROMPT]])
|
||||
try:
|
||||
@@ -2074,11 +2179,259 @@ class ProviderCallNode(Node):
|
||||
|
||||
# Подготовим Prompt Blocks + pm-структуру для шаблона
|
||||
unified_msgs = self._render_blocks_to_unified(context)
|
||||
pm_struct = self._blocks_struct_for_template(provider, unified_msgs, context)
|
||||
adapter = None
|
||||
try:
|
||||
adapter = get_adapter(provider)
|
||||
except Exception:
|
||||
adapter = None
|
||||
if adapter:
|
||||
blocks_struct = adapter.blocks_struct_for_template(unified_msgs, context, self.config or {})
|
||||
else:
|
||||
blocks_struct = self._blocks_struct_for_template(provider, unified_msgs, context)
|
||||
pm_struct = dict(blocks_struct)
|
||||
|
||||
# Расширяем контекст для рендера шаблонов
|
||||
render_ctx = dict(context)
|
||||
render_ctx["pm"] = pm_struct
|
||||
# Прокинем конфиг ноды в контекст для адаптеров (например, claude_no_system)
|
||||
try:
|
||||
render_ctx["_node_config"] = dict(self.config or {})
|
||||
except Exception:
|
||||
render_ctx["_node_config"] = {}
|
||||
# Node-local: track VAR paths overridden by prompt_preprocess (no extra syntax)
|
||||
pre_var_paths = set()
|
||||
|
||||
# prompt_preprocess (pre-merge DSL): парсим строки до prompt_combine и готовим «пред‑сегменты»
|
||||
# Синтаксис строки:
|
||||
# SEGMENT [delKeyContains "needle"] [delpos=prepend|append|N|-1] [pruneEmpty] [case=ci|cs]
|
||||
# По умолчанию: case=ci, pruneEmpty=false, без delpos → append
|
||||
# SEGMENT поддерживает [[...]] и {{ ... }}
|
||||
pre_segments_raw: List[Dict[str, Any]] = []
|
||||
try:
|
||||
pre_raw = str((self.config or {}).get("prompt_preprocess") or "").strip()
|
||||
except Exception:
|
||||
pre_raw = ""
|
||||
if pre_raw:
|
||||
lines = [ln.strip() for ln in pre_raw.splitlines() if str(ln or "").strip()]
|
||||
import re as _repp
|
||||
# Collect local overrides for plain [[VAR:path]] segments after filtering
|
||||
pre_var_overrides: Dict[str, Any] = {}
|
||||
|
||||
def _pp_try_json(s: str) -> Any:
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except Exception:
|
||||
try:
|
||||
obj = json.loads(s, strict=False) # type: ignore[call-arg]
|
||||
except Exception:
|
||||
return None
|
||||
for _ in range(2):
|
||||
if isinstance(obj, str):
|
||||
st = obj.strip()
|
||||
if (st.startswith("{") and st.endswith("}")) or (st.startswith("[") and st.endswith("]")):
|
||||
try:
|
||||
obj = json.loads(st)
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
break
|
||||
return obj
|
||||
|
||||
def _norm(s: Any, ci: bool) -> str:
|
||||
try:
|
||||
ss = s if isinstance(s, str) else str(s)
|
||||
except Exception:
|
||||
ss = ""
|
||||
return ss.lower() if ci else ss
|
||||
|
||||
def _delkeys_by_val_contains(x: Any, needles: List[str], ci: bool, prune_empty: bool, is_root: bool = False) -> tuple[Any, int]:
|
||||
"""
|
||||
Рекурсивно удаляет ключи словаря, если строковое представление их значения содержит needle.
|
||||
- needles: список подстрок
|
||||
- ci: регистронезависимый поиск (по умолчанию True)
|
||||
- prune_empty: удалять пустые {} / [] из родителей (кроме корня)
|
||||
Возвращает (новое_значение_или_None, удалённые_ключи_суммарно)
|
||||
"""
|
||||
removed = 0
|
||||
if isinstance(x, dict):
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in x.items():
|
||||
v2, rem2 = _delkeys_by_val_contains(v, needles, ci, prune_empty, False)
|
||||
removed += rem2
|
||||
sv = _stringify_for_template(v2)
|
||||
cond = False
|
||||
try:
|
||||
nsv = _norm(sv, ci)
|
||||
for nd in needles:
|
||||
nds = _norm(nd, ci)
|
||||
if nds and (nds in nsv):
|
||||
cond = True
|
||||
break
|
||||
except Exception:
|
||||
cond = False
|
||||
if cond:
|
||||
removed += 1
|
||||
continue
|
||||
out[k] = v2
|
||||
if prune_empty and not is_root and len(out) == 0:
|
||||
return None, removed # type: ignore[return-value]
|
||||
return out, removed
|
||||
if isinstance(x, list):
|
||||
arr: List[Any] = []
|
||||
for it in x:
|
||||
it2, rem2 = _delkeys_by_val_contains(it, needles, ci, prune_empty, False)
|
||||
removed += rem2
|
||||
if it2 is None and prune_empty:
|
||||
continue
|
||||
arr.append(it2)
|
||||
if prune_empty and not is_root and len(arr) == 0:
|
||||
return None, removed # type: ignore[return-value]
|
||||
return arr, removed
|
||||
# scalar
|
||||
return x, removed
|
||||
|
||||
pre_used = 0
|
||||
pre_removed_total = 0
|
||||
for ln in lines:
|
||||
try:
|
||||
# Опции
|
||||
needles = [m.group(1) for m in _repp.finditer(r'(?is)\bdelKeyContains\s+"([^"]*)"', ln)]
|
||||
mpos = _repp.search(r'(?is)\bdelpos\s*=\s*(prepend|append|-?\d+)\b', ln)
|
||||
pos_spec = mpos.group(1).strip().lower() if mpos else None
|
||||
mcase = _repp.search(r'(?is)\bcase\s*=\s*(ci|cs)\b', ln)
|
||||
ci = True if not mcase else (mcase.group(1).strip().lower() == "ci")
|
||||
prune = bool(_repp.search(r'(?is)\bpruneEmpty\b', ln))
|
||||
|
||||
# Очищаем директивы из текста строки → остаётся сам SEGMENT
|
||||
s2 = _repp.sub(r'(?is)\bdelKeyContains\s+"[^"]*"', "", ln)
|
||||
s2 = _repp.sub(r'(?is)\bdelpos\s*=\s*(prepend|append|-?\d+)\b', "", s2)
|
||||
s2 = _repp.sub(r'(?is)\bcase\s*=\s*(ci|cs)\b', "", s2)
|
||||
s2 = _repp.sub(r'(?is)\bpruneEmpty\b', "", s2)
|
||||
seg = s2.strip()
|
||||
if not seg:
|
||||
continue
|
||||
# Try to detect plain [[VAR:path]] to support node-local override without extra syntax
|
||||
var_path = None
|
||||
try:
|
||||
mvar = _VAR_MACRO_RE.fullmatch(seg)
|
||||
if mvar:
|
||||
var_path = (mvar.group(1) or "").strip()
|
||||
except Exception:
|
||||
var_path = None
|
||||
|
||||
# Макросы и попытка распарсить JSON
|
||||
resolved = render_template_simple(seg, render_ctx, render_ctx.get("OUT") or {})
|
||||
obj = _pp_try_json(resolved)
|
||||
base = obj if obj is not None else resolved
|
||||
|
||||
# Удаление ключей по contains, если задано
|
||||
if needles:
|
||||
try:
|
||||
base2, remcnt = _delkeys_by_val_contains(base, needles, ci, prune, True)
|
||||
except Exception:
|
||||
base2, remcnt = base, 0
|
||||
else:
|
||||
base2, remcnt = base, 0
|
||||
|
||||
# If the segment was a pure [[VAR:path]] and we had filters,
|
||||
# locally override this VAR for the rest of the node (so prompt_combine sees the filtered value)
|
||||
try:
|
||||
if var_path and needles:
|
||||
pre_var_overrides[var_path] = base2
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pre_segments_raw.append({"obj": base2, "pos": pos_spec})
|
||||
pre_used += 1
|
||||
pre_removed_total += int(remcnt or 0)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Apply local VAR overrides onto render_ctx (node-local, no extra syntax)
|
||||
try:
|
||||
import copy as _copy
|
||||
|
||||
def _safe_deepcopy(x: Any) -> Any:
|
||||
try:
|
||||
return _copy.deepcopy(x)
|
||||
except Exception:
|
||||
# Fallback deep copy for dict/list; otherwise return as-is
|
||||
try:
|
||||
if isinstance(x, dict):
|
||||
return {k: _safe_deepcopy(v) for k, v in x.items()}
|
||||
if isinstance(x, list):
|
||||
return [_safe_deepcopy(i) for i in x]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return json.loads(json.dumps(x))
|
||||
except Exception:
|
||||
return x
|
||||
|
||||
def _set_by_path(obj: Any, path: str, value: Any) -> None:
|
||||
cur = obj
|
||||
parts = [p.strip() for p in str(path).split(".") if p.strip()]
|
||||
for i, part in enumerate(parts):
|
||||
# list index?
|
||||
idx = None
|
||||
try:
|
||||
idx = int(part)
|
||||
except Exception:
|
||||
idx = None
|
||||
last = (i == len(parts) - 1)
|
||||
if idx is not None:
|
||||
if not isinstance(cur, list) or idx < 0 or idx >= len(cur):
|
||||
return
|
||||
if last:
|
||||
cur[idx] = value
|
||||
return
|
||||
cur = cur[idx]
|
||||
continue
|
||||
# dict key
|
||||
if not isinstance(cur, dict):
|
||||
return
|
||||
if last:
|
||||
cur[part] = value
|
||||
return
|
||||
if part not in cur or not isinstance(cur[part], (dict, list)):
|
||||
cur[part] = {}
|
||||
cur = cur[part]
|
||||
|
||||
# Deep-copy only the top-level roots we are going to mutate (e.g., 'incoming' for 'incoming.*')
|
||||
roots_to_copy: set = set()
|
||||
for _p in (pre_var_overrides or {}).keys():
|
||||
if "." in str(_p):
|
||||
roots_to_copy.add(str(_p).split(".", 1)[0].strip())
|
||||
for _root in roots_to_copy:
|
||||
try:
|
||||
if _root in render_ctx:
|
||||
render_ctx[_root] = _safe_deepcopy(render_ctx[_root])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for _p, _v in (pre_var_overrides or {}).items():
|
||||
_set_by_path(render_ctx, _p, _v)
|
||||
pre_var_paths = set(pre_var_overrides.keys())
|
||||
except Exception:
|
||||
pre_var_paths = set()
|
||||
# SSE: prompt_preprocess summary
|
||||
try:
|
||||
trace_fn = context.get("_trace")
|
||||
if trace_fn:
|
||||
await trace_fn({
|
||||
"event": "prompt_preprocess",
|
||||
"node_id": self.node_id,
|
||||
"node_type": self.type_name,
|
||||
"provider": provider,
|
||||
"lines": len(lines),
|
||||
"used": pre_used,
|
||||
"removed_keys": pre_removed_total,
|
||||
"ts": int(time.time() * 1000),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
pre_segments_raw = []
|
||||
|
||||
# prompt_combine (DSL "&"): комбинируем сегменты в заданном порядке.
|
||||
# Расширения:
|
||||
@@ -2090,7 +2443,35 @@ class ProviderCallNode(Node):
|
||||
combine_raw = str(cfg.get("prompt_combine") or "").strip()
|
||||
except Exception:
|
||||
combine_raw = ""
|
||||
if combine_raw:
|
||||
# Быстрая ветка: если есть адаптер — выполняем merge сегментов через него и коротко-замыкаем легаси-блок ниже
|
||||
raw_segs_for_adapter = [s.strip() for s in combine_raw.split("&") if str(s or "").strip()]
|
||||
if adapter and (raw_segs_for_adapter or (pre_segments_raw and len(pre_segments_raw) > 0)):
|
||||
try:
|
||||
pm_struct = adapter.combine_segments(
|
||||
blocks_struct=blocks_struct,
|
||||
pre_segments_raw=pre_segments_raw,
|
||||
raw_segs=raw_segs_for_adapter,
|
||||
render_ctx=render_ctx,
|
||||
pre_var_paths=pre_var_paths,
|
||||
render_template_simple_fn=render_template_simple,
|
||||
var_macro_fullmatch_re=_VAR_MACRO_RE,
|
||||
detect_vendor_fn=detect_vendor,
|
||||
)
|
||||
# обновим pm в контексте
|
||||
try:
|
||||
render_ctx["pm"] = pm_struct
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
try:
|
||||
print(f"TRACE adapter_combine_error: node={self.node_id} provider={provider} err={_e}")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# коротко-замкнём легаси-блок prompt_combine ниже
|
||||
pre_segments_raw = []
|
||||
combine_raw = ""
|
||||
if combine_raw or (pre_segments_raw and len(pre_segments_raw) > 0):
|
||||
raw_segs = [s.strip() for s in combine_raw.split("&") if str(s or "").strip()]
|
||||
|
||||
def _try_json(s: str) -> Any:
|
||||
@@ -2547,12 +2928,36 @@ class ProviderCallNode(Node):
|
||||
if provider in {"gemini", "gemini_image"}:
|
||||
built: List[Dict[str, Any]] = []
|
||||
sys_texts: List[str] = []
|
||||
# Preprocess-inserted segments (prompt_preprocess)
|
||||
for _pre in pre_segments_raw:
|
||||
try:
|
||||
_obj = _pre.get("obj")
|
||||
items = _as_gemini_contents(_obj)
|
||||
items = _filter_gemini(items)
|
||||
built = _insert_items(built, items, _pre.get("pos"))
|
||||
try:
|
||||
sx = _extract_sys_text_from_obj(_obj)
|
||||
if isinstance(sx, str) and sx.strip():
|
||||
sys_texts.append(sx.strip())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
for raw_seg in raw_segs:
|
||||
body_seg, pos_spec = _split_pos_spec(raw_seg)
|
||||
if body_seg == "[[PROMPT]]":
|
||||
items = _filter_gemini(list(blocks_struct.get("contents", []) or []))
|
||||
built = _insert_items(built, items, pos_spec)
|
||||
continue
|
||||
m_pre = _VAR_MACRO_RE.fullmatch(body_seg)
|
||||
if m_pre:
|
||||
_p = (m_pre.group(1) or "").strip()
|
||||
try:
|
||||
if _p in pre_var_paths:
|
||||
# Skip duplicate var segment - already inserted via prompt_preprocess (filtered)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
resolved = render_template_simple(body_seg, render_ctx, render_ctx.get("OUT") or {})
|
||||
obj = _try_json(resolved)
|
||||
# provider guess + system extract for cross-provider combine
|
||||
@@ -2585,12 +2990,36 @@ class ProviderCallNode(Node):
|
||||
elif provider in {"openai"}:
|
||||
built2: List[Dict[str, Any]] = []
|
||||
sys_texts: List[str] = []
|
||||
# Preprocess-inserted segments (prompt_preprocess)
|
||||
for _pre in pre_segments_raw:
|
||||
try:
|
||||
_obj = _pre.get("obj")
|
||||
items = _as_openai_messages(_obj)
|
||||
items = _filter_openai(items)
|
||||
built2 = _insert_items(built2, items, _pre.get("pos"))
|
||||
try:
|
||||
sx = _extract_sys_text_from_obj(_obj)
|
||||
if isinstance(sx, str) and sx.strip():
|
||||
sys_texts.append(sx.strip())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
for raw_seg in raw_segs:
|
||||
body_seg, pos_spec = _split_pos_spec(raw_seg)
|
||||
if body_seg == "[[PROMPT]]":
|
||||
items = _filter_openai(list(blocks_struct.get("messages", []) or []))
|
||||
built2 = _insert_items(built2, items, pos_spec)
|
||||
continue
|
||||
m_pre = _VAR_MACRO_RE.fullmatch(body_seg)
|
||||
if m_pre:
|
||||
_p = (m_pre.group(1) or "").strip()
|
||||
try:
|
||||
if _p in pre_var_paths:
|
||||
# Skip duplicate var segment - already inserted via prompt_preprocess (filtered)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
resolved = render_template_simple(body_seg, render_ctx, render_ctx.get("OUT") or {})
|
||||
obj = _try_json(resolved)
|
||||
try:
|
||||
@@ -2622,12 +3051,36 @@ class ProviderCallNode(Node):
|
||||
else: # claude
|
||||
built3: List[Dict[str, Any]] = []
|
||||
sys_texts: List[str] = []
|
||||
# Preprocess-inserted segments (prompt_preprocess)
|
||||
for _pre in pre_segments_raw:
|
||||
try:
|
||||
_obj = _pre.get("obj")
|
||||
items = _as_claude_messages(_obj)
|
||||
items = _filter_claude(items)
|
||||
built3 = _insert_items(built3, items, _pre.get("pos"))
|
||||
try:
|
||||
sx = _extract_sys_text_from_obj(_obj)
|
||||
if isinstance(sx, str) and sx.strip():
|
||||
sys_texts.append(sx.strip())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
for raw_seg in raw_segs:
|
||||
body_seg, pos_spec = _split_pos_spec(raw_seg)
|
||||
if body_seg == "[[PROMPT]]":
|
||||
items = _filter_claude(list(blocks_struct.get("messages", []) or []))
|
||||
built3 = _insert_items(built3, items, pos_spec)
|
||||
continue
|
||||
m_pre = _VAR_MACRO_RE.fullmatch(body_seg)
|
||||
if m_pre:
|
||||
_p = (m_pre.group(1) or "").strip()
|
||||
try:
|
||||
if _p in pre_var_paths:
|
||||
# Skip duplicate var segment - already inserted via prompt_preprocess (filtered)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
resolved = render_template_simple(body_seg, render_ctx, render_ctx.get("OUT") or {})
|
||||
obj = _try_json(resolved)
|
||||
try:
|
||||
@@ -2652,6 +3105,9 @@ class ProviderCallNode(Node):
|
||||
if isinstance(existing_sys, list):
|
||||
sys_blocks.extend(existing_sys)
|
||||
st0 = blocks_struct.get("system_text") or ""
|
||||
# Ensure PROMPT system_text from blocks is included as a Claude system block
|
||||
if isinstance(st0, str) and st0.strip():
|
||||
sys_blocks.append({"type": "text", "text": st0})
|
||||
for s in sys_texts:
|
||||
sys_blocks.append({"type": "text", "text": s})
|
||||
st = "\n\n".join([t for t in [st0] + sys_texts if isinstance(t, str) and t.strip()])
|
||||
@@ -2671,8 +3127,11 @@ class ProviderCallNode(Node):
|
||||
else:
|
||||
# Prefer top-level system as plain string (proxy compatibility)
|
||||
pm_struct = {"messages": built3, "system_text": st}
|
||||
if st:
|
||||
pm_struct["system"] = st
|
||||
# Prefer array of system blocks when possible; fallback to single text block
|
||||
if sys_blocks:
|
||||
pm_struct["system"] = sys_blocks
|
||||
elif st:
|
||||
pm_struct["system"] = [{"type": "text", "text": st}]
|
||||
|
||||
# SSE метрика
|
||||
try:
|
||||
@@ -2695,49 +3154,41 @@ class ProviderCallNode(Node):
|
||||
# Единый JSON-фрагмент PROMPT для шаблонов: [[PROMPT]]
|
||||
prompt_fragment = ""
|
||||
try:
|
||||
if provider == "openai":
|
||||
prompt_fragment = '"messages": ' + json.dumps(pm_struct.get("messages", []), ensure_ascii=False)
|
||||
elif provider == "gemini":
|
||||
parts = []
|
||||
contents = pm_struct.get("contents")
|
||||
if contents is not None:
|
||||
parts.append('"contents": ' + json.dumps(contents, ensure_ascii=False))
|
||||
sysi = pm_struct.get("systemInstruction")
|
||||
# даже если пустой объект {}, это валидно
|
||||
if sysi is not None:
|
||||
parts.append('"systemInstruction": ' + json.dumps(sysi, ensure_ascii=False))
|
||||
prompt_fragment = ", ".join(parts)
|
||||
elif provider == "gemini_image":
|
||||
# Используем ту же структуру PROMPT, что и для Gemini (generateContent)
|
||||
parts = []
|
||||
contents = pm_struct.get("contents")
|
||||
if contents is not None:
|
||||
parts.append('"contents": ' + json.dumps(contents, ensure_ascii=False))
|
||||
sysi = pm_struct.get("systemInstruction")
|
||||
if sysi is not None:
|
||||
parts.append('"systemInstruction": ' + json.dumps(sysi, ensure_ascii=False))
|
||||
prompt_fragment = ", ".join(parts)
|
||||
elif provider == "claude":
|
||||
parts = []
|
||||
# Учитываем флаг совместимости: при claude_no_system не добавляем top-level "system"
|
||||
claude_no_system = False
|
||||
try:
|
||||
claude_no_system = bool((self.config or {}).get("claude_no_system", False))
|
||||
except Exception:
|
||||
if adapter:
|
||||
prompt_fragment = adapter.prompt_fragment(pm_struct, self.config or {})
|
||||
else:
|
||||
if provider == "openai":
|
||||
prompt_fragment = '"messages": ' + json.dumps(pm_struct.get("messages", []), ensure_ascii=False)
|
||||
elif provider in {"gemini", "gemini_image"}:
|
||||
parts = []
|
||||
contents = pm_struct.get("contents")
|
||||
if contents is not None:
|
||||
parts.append('"contents": ' + json.dumps(contents, ensure_ascii=False))
|
||||
sysi = pm_struct.get("systemInstruction")
|
||||
if sysi is not None:
|
||||
parts.append('"systemInstruction": ' + json.dumps(sysi, ensure_ascii=False))
|
||||
prompt_fragment = ", ".join(parts)
|
||||
elif provider == "claude":
|
||||
parts = []
|
||||
# Учитываем флаг совместимости: при claude_no_system не добавляем top-level "system"
|
||||
claude_no_system = False
|
||||
try:
|
||||
claude_no_system = bool((self.config or {}).get("claude_no_system", False))
|
||||
except Exception:
|
||||
claude_no_system = False
|
||||
|
||||
if not claude_no_system:
|
||||
# Предпочитаем массив блоков system, если он есть; иначе строковый system_text
|
||||
sys_val = pm_struct.get("system", None)
|
||||
if sys_val is None:
|
||||
sys_val = pm_struct.get("system_text")
|
||||
if sys_val:
|
||||
parts.append('"system": ' + json.dumps(sys_val, ensure_ascii=False))
|
||||
if not claude_no_system:
|
||||
# Предпочитаем массив блоков system, если он есть; иначе строковый system_text
|
||||
sys_val = pm_struct.get("system", None)
|
||||
if sys_val is None:
|
||||
sys_val = pm_struct.get("system_text")
|
||||
if sys_val:
|
||||
parts.append('"system": ' + json.dumps(sys_val, ensure_ascii=False))
|
||||
|
||||
msgs = pm_struct.get("messages")
|
||||
if msgs is not None:
|
||||
parts.append('"messages": ' + json.dumps(msgs, ensure_ascii=False))
|
||||
prompt_fragment = ", ".join(parts)
|
||||
msgs = pm_struct.get("messages")
|
||||
if msgs is not None:
|
||||
parts.append('"messages": ' + json.dumps(msgs, ensure_ascii=False))
|
||||
prompt_fragment = ", ".join(parts)
|
||||
except Exception: # noqa: BLE001
|
||||
prompt_fragment = ""
|
||||
render_ctx["PROMPT"] = prompt_fragment
|
||||
@@ -2809,6 +3260,24 @@ class ProviderCallNode(Node):
|
||||
pass
|
||||
# SSE: http_req (как в Burp)
|
||||
req_id = f"{self.node_id}-{int(time.time()*1000)}"
|
||||
# Register original (untrimmed) request details for manual resend
|
||||
try:
|
||||
pipeline_id = str((context.get("meta") or {}).get("id", "pipeline_editor"))
|
||||
except Exception:
|
||||
pipeline_id = "pipeline_editor"
|
||||
try:
|
||||
register_http_request(req_id, {
|
||||
"pipeline_id": pipeline_id,
|
||||
"node_id": self.node_id,
|
||||
"node_type": self.type_name,
|
||||
"provider": provider,
|
||||
"method": "POST",
|
||||
"url": url,
|
||||
"headers": dict(final_headers),
|
||||
"body_json": payload, # untrimmed original payload
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
trace_fn = context.get("_trace")
|
||||
if trace_fn:
|
||||
@@ -2836,7 +3305,26 @@ class ProviderCallNode(Node):
|
||||
st: Optional[int] = None
|
||||
async with build_client(timeout=timeout_sec) as client:
|
||||
body_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
resp = await client.post(url, content=body_bytes, headers=final_headers)
|
||||
# Cooperative cancel pre-check (avoid starting new HTTP on abort)
|
||||
try:
|
||||
pipeline_id = str((context.get("meta") or {}).get("id", "pipeline_editor"))
|
||||
except Exception:
|
||||
pipeline_id = "pipeline_editor"
|
||||
try:
|
||||
if is_cancelled(pipeline_id) and get_cancel_mode(pipeline_id) == "abort":
|
||||
try:
|
||||
print(f"TRACE http_cancel_pre: {self.node_id} abort before request")
|
||||
except Exception:
|
||||
pass
|
||||
raise ExecutionError("Cancelled by user (abort)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _do_post():
|
||||
return await client.post(url, content=body_bytes, headers=final_headers)
|
||||
|
||||
# Await HTTP with cooperative cancel/abort handling
|
||||
resp = await _await_coro_with_cancel(_do_post(), pipeline_id)
|
||||
# Do not raise_for_status: keep body/logs on 4xx/5xx
|
||||
try:
|
||||
print("===== ProviderCall RESPONSE BEGIN =====")
|
||||
@@ -3166,13 +3654,19 @@ class RawForwardNode(Node):
|
||||
# Автодетекция вендора для базового URL если base_url не задан
|
||||
if not base_url:
|
||||
vendor = detect_vendor(raw_payload)
|
||||
if vendor == "openai":
|
||||
base_url = "https://api.openai.com"
|
||||
elif vendor == "claude":
|
||||
base_url = "https://api.anthropic.com"
|
||||
elif vendor == "gemini":
|
||||
base_url = "https://generativelanguage.googleapis.com"
|
||||
else:
|
||||
base_url = None
|
||||
try:
|
||||
base_url = _adapter_default_base_url_for(vendor)
|
||||
except Exception:
|
||||
base_url = None
|
||||
if not base_url:
|
||||
if vendor == "openai":
|
||||
base_url = "https://api.openai.com"
|
||||
elif vendor == "claude":
|
||||
base_url = "https://api.anthropic.com"
|
||||
elif vendor == "gemini":
|
||||
base_url = "https://generativelanguage.googleapis.com"
|
||||
if not base_url:
|
||||
raise ExecutionError(
|
||||
f"Node {self.node_id} ({self.type_name}): 'base_url' is not configured and vendor could not be detected."
|
||||
)
|
||||
@@ -3288,6 +3782,31 @@ class RawForwardNode(Node):
|
||||
|
||||
# SSE: http_req
|
||||
req_id = f"{self.node_id}-{int(time.time()*1000)}"
|
||||
# Register original (untrimmed) request details for manual resend
|
||||
try:
|
||||
pipeline_id = str((context.get("meta") or {}).get("id", "pipeline_editor"))
|
||||
except Exception:
|
||||
pipeline_id = "pipeline_editor"
|
||||
try:
|
||||
reg_info: Dict[str, Any] = {
|
||||
"pipeline_id": pipeline_id,
|
||||
"node_id": self.node_id,
|
||||
"node_type": self.type_name,
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": dict(headers),
|
||||
}
|
||||
if raw_payload is not None:
|
||||
reg_info["body_json"] = raw_payload # original JSON body
|
||||
# Always keep original textual/bytes preview for non-JSON
|
||||
reg_info["body_text"] = body_text
|
||||
try:
|
||||
reg_info["body_bytes_len"] = int(len(body_bytes or b""))
|
||||
except Exception:
|
||||
pass
|
||||
register_http_request(req_id, reg_info)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
trace_fn = context.get("_trace")
|
||||
if trace_fn:
|
||||
@@ -3309,7 +3828,26 @@ class RawForwardNode(Node):
|
||||
async with build_client(timeout=timeout_sec) as client:
|
||||
# Для GET/HEAD обычно не отправляем body
|
||||
send_content = None if method in {"GET", "HEAD"} else body_bytes
|
||||
resp = await client.request(method, url, headers=headers, content=send_content)
|
||||
# Cooperative cancel pre-check (avoid starting new HTTP on abort)
|
||||
try:
|
||||
pipeline_id = str((context.get("meta") or {}).get("id", "pipeline_editor"))
|
||||
except Exception:
|
||||
pipeline_id = "pipeline_editor"
|
||||
try:
|
||||
if is_cancelled(pipeline_id) and get_cancel_mode(pipeline_id) == "abort":
|
||||
try:
|
||||
print(f"TRACE http_cancel_pre: {self.node_id} abort before request")
|
||||
except Exception:
|
||||
pass
|
||||
raise ExecutionError("Cancelled by user (abort)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _do_req():
|
||||
return await client.request(method, url, headers=headers, content=send_content)
|
||||
|
||||
# Await HTTP with cooperative cancel/abort handling
|
||||
resp = await _await_coro_with_cancel(_do_req(), pipeline_id)
|
||||
|
||||
# Ответ: лог/печать
|
||||
try:
|
||||
@@ -3628,6 +4166,22 @@ async def _providercall_run_with_while(self, inputs, context):
|
||||
last_idx = -1
|
||||
|
||||
for i in range(max_iters):
|
||||
# Cancel check before starting next iteration
|
||||
try:
|
||||
pid = str((context.get("meta") or {}).get("id", "pipeline_editor"))
|
||||
except Exception:
|
||||
pid = "pipeline_editor"
|
||||
try:
|
||||
if is_cancelled(pid):
|
||||
mode = get_cancel_mode(pid)
|
||||
try:
|
||||
print(f"TRACE while_cancel: {self.node_id} mode={mode} at i={i} (pre)")
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build loop-local context with cycleindex and WAS_ERROR
|
||||
ctx2 = dict(context or {})
|
||||
try:
|
||||
@@ -3651,6 +4205,13 @@ async def _providercall_run_with_while(self, inputs, context):
|
||||
if i == 0:
|
||||
cond = True
|
||||
else:
|
||||
# Inject previous iteration error flag so [[WAS_ERROR]] in while_expr refers to the last iteration
|
||||
try:
|
||||
vmap2 = dict(ctx2.get("vars") or {})
|
||||
except Exception:
|
||||
vmap2 = {}
|
||||
vmap2["WAS_ERROR"] = bool(last_was_error)
|
||||
ctx2["vars"] = vmap2
|
||||
# Augment OUT with the last output of this node so [[OUTn]] / [[OUT:nX...]] can see it
|
||||
try:
|
||||
out_aug = dict(out_map or {})
|
||||
@@ -3695,7 +4256,7 @@ async def _providercall_run_with_while(self, inputs, context):
|
||||
# Single iteration run; reenter node.run with _in_while flag set
|
||||
try:
|
||||
inner_out = await self.run(inputs, ctx2)
|
||||
except Exception as exc: # network or other runtime error
|
||||
except BaseException as exc:
|
||||
if ignore:
|
||||
inner_out = {"result": {"error": str(exc)}, "response_text": ""}
|
||||
try:
|
||||
@@ -3769,6 +4330,22 @@ async def _rawforward_run_with_while(self, inputs, context):
|
||||
last_idx = -1
|
||||
|
||||
for i in range(max_iters):
|
||||
# Cancel check before starting next iteration
|
||||
try:
|
||||
pid = str((context.get("meta") or {}).get("id", "pipeline_editor"))
|
||||
except Exception:
|
||||
pid = "pipeline_editor"
|
||||
try:
|
||||
if is_cancelled(pid):
|
||||
mode = get_cancel_mode(pid)
|
||||
try:
|
||||
print(f"TRACE while_cancel: {self.node_id} mode={mode} at i={i} (pre)")
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ctx2 = dict(context or {})
|
||||
try:
|
||||
vmap = dict(ctx2.get("vars") or {})
|
||||
@@ -3787,6 +4364,13 @@ async def _rawforward_run_with_while(self, inputs, context):
|
||||
if i == 0:
|
||||
cond = True
|
||||
else:
|
||||
# Inject previous iteration error flag so [[WAS_ERROR]] in while_expr refers to the last iteration
|
||||
try:
|
||||
vmap2 = dict(ctx2.get("vars") or {})
|
||||
except Exception:
|
||||
vmap2 = {}
|
||||
vmap2["WAS_ERROR"] = bool(last_was_error)
|
||||
ctx2["vars"] = vmap2
|
||||
try:
|
||||
out_aug = dict(out_map or {})
|
||||
out_aug[self.node_id] = dict(last_out or {})
|
||||
@@ -3829,7 +4413,7 @@ async def _rawforward_run_with_while(self, inputs, context):
|
||||
|
||||
try:
|
||||
inner_out = await self.run(inputs, ctx2)
|
||||
except Exception as exc:
|
||||
except BaseException as exc:
|
||||
if ignore:
|
||||
inner_out = {"result": {"error": str(exc)}, "response_text": ""}
|
||||
try:
|
||||
|
||||
@@ -594,6 +594,12 @@ def _tokenize_condition_expr(expr: str, context: Dict[str, Any], out_map: Dict[s
|
||||
while j < n and (expr[j].isalnum() or expr[j] in "._"):
|
||||
j += 1
|
||||
word = expr[i:j]
|
||||
lw = word.lower()
|
||||
# Литералы: true/false/null (любая раскладка) → Python-константы
|
||||
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", word) and lw in {"true", "false", "null"}:
|
||||
tokens.append("True" if lw == "true" else ("False" if lw == "false" else "None"))
|
||||
i = j
|
||||
continue
|
||||
# Поддержка «голых» идентификаторов из vars: cycleindex, WAS_ERROR и т.п.
|
||||
# Если это простой идентификатор (без точек) и он есть в context.vars — биндим его значением.
|
||||
try:
|
||||
@@ -752,17 +758,19 @@ def _safe_eval_bool(py_expr: str, bindings: Dict[str, Any]) -> bool:
|
||||
if isinstance(node.op, ast.Not):
|
||||
return (not val)
|
||||
if isinstance(node, ast.BoolOp) and isinstance(node.op, tuple(allowed_boolops)):
|
||||
vals = [bool(eval_node(v)) for v in node.values]
|
||||
# Короткое замыкание:
|
||||
# AND — при первом False прекращаем и возвращаем False; иначе True
|
||||
# OR — при первом True прекращаем и возвращаем True; иначе False
|
||||
if isinstance(node.op, ast.And):
|
||||
res = True
|
||||
for v in vals:
|
||||
res = res and v
|
||||
return res
|
||||
for v in node.values:
|
||||
if not bool(eval_node(v)):
|
||||
return False
|
||||
return True
|
||||
if isinstance(node.op, ast.Or):
|
||||
res = False
|
||||
for v in vals:
|
||||
res = res or v
|
||||
return res
|
||||
for v in node.values:
|
||||
if bool(eval_node(v)):
|
||||
return True
|
||||
return False
|
||||
if isinstance(node, ast.Compare):
|
||||
left = eval_node(node.left)
|
||||
for opnode, comparator in zip(node.ops, node.comparators):
|
||||
|
||||
Reference in New Issue
Block a user