programgarden 1.26.0__tar.gz → 1.27.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {programgarden-1.26.0 → programgarden-1.27.0}/PKG-INFO +1 -1
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/__init__.py +1 -1
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/executor.py +173 -106
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resolver.py +194 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/pyproject.toml +1 -1
- {programgarden-1.26.0 → programgarden-1.27.0}/README.md +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/client.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/code_worker.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/context.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/validation_recommender.py +0 -0
|
@@ -3129,11 +3129,15 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
3129
3129
|
"paper_trading=False로 설정하거나, 해외선물(overseas_futures)을 사용하세요.",
|
|
3130
3130
|
node_id,
|
|
3131
3131
|
)
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3132
|
+
# 하드 실패(지원하지 않는 설정 조합) → raise. 에러-dict 로 굴러가면 하류가
|
|
3133
|
+
# connected/connection 을 침묵의 False/None 으로 먹고 워크플로우가 완주한다.
|
|
3134
|
+
from programgarden_core.exceptions import ValidationError
|
|
3135
|
+
raise ValidationError(
|
|
3136
|
+
"overseas_stock does not support paper_trading (LS-Sec limitation). Set "
|
|
3137
|
+
"paper_trading=False, or use overseas_futures for paper trading.",
|
|
3138
|
+
node_id=node_id,
|
|
3139
|
+
)
|
|
3140
|
+
|
|
3137
3141
|
# ========================================
|
|
3138
3142
|
# Credential 자동 주입 (GenericNodeExecutor와 동일 패턴)
|
|
3139
3143
|
# credential_id가 있으면 appkey, appsecret이 config에 주입됨
|
|
@@ -7789,6 +7793,26 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
7789
7793
|
return str(value)[:15] + "..." if len(str(value)) > 15 else str(value)
|
|
7790
7794
|
return str(value)[:20]
|
|
7791
7795
|
|
|
7796
|
+
def _normalize_columns(self, columns: Any) -> Optional[List[tuple]]:
|
|
7797
|
+
"""`columns` 설정을 [(key, label), ...] 로 정규화.
|
|
7798
|
+
|
|
7799
|
+
스키마 정본은 `List[str]`(예: ["symbol","close"])이지만, LLM 이 리치 객체형
|
|
7800
|
+
[{"key":"date","label":"날짜"}] 을 뱉는 경우가 있다. 이때 옛 렌더러는
|
|
7801
|
+
`f"{c:<12}"`(c=dict)에서 `unsupported format string passed to dict.__format__`
|
|
7802
|
+
로 크래시했다. 양형을 모두 받아 (조회 key, 표시 label)로 환원한다."""
|
|
7803
|
+
if not columns:
|
|
7804
|
+
return None
|
|
7805
|
+
norm: List[tuple] = []
|
|
7806
|
+
for c in columns:
|
|
7807
|
+
if isinstance(c, dict):
|
|
7808
|
+
key = c.get("key") or c.get("name") or c.get("field") or c.get("label")
|
|
7809
|
+
label = c.get("label") or c.get("title") or key
|
|
7810
|
+
if key:
|
|
7811
|
+
norm.append((str(key), str(label)))
|
|
7812
|
+
elif c is not None:
|
|
7813
|
+
norm.append((str(c), str(c)))
|
|
7814
|
+
return norm or None
|
|
7815
|
+
|
|
7792
7816
|
def _get_data(self, config: Dict[str, Any], context: ExecutionContext, node_id: str) -> Any:
|
|
7793
7817
|
"""data 필드 또는 엣지에서 데이터 가져오기"""
|
|
7794
7818
|
# 1. config의 data 필드가 바인딩 표현식인 경우 이미 평가됨
|
|
@@ -7899,12 +7923,16 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
7899
7923
|
rows = sorted(rows, key=lambda x: x.get(sort_by, 0), reverse=(sort_order == "desc"))
|
|
7900
7924
|
rows = rows[:limit]
|
|
7901
7925
|
|
|
7902
|
-
cols = columns or
|
|
7903
|
-
|
|
7926
|
+
cols = self._normalize_columns(columns) or [
|
|
7927
|
+
(k, k) for k in list(rows[0].keys())[:8]
|
|
7928
|
+
]
|
|
7929
|
+
header = " | ".join(f"{label:<12}" for _, label in cols)
|
|
7904
7930
|
print(header)
|
|
7905
7931
|
print("-" * 80)
|
|
7906
7932
|
for row in rows:
|
|
7907
|
-
values = " | ".join(
|
|
7933
|
+
values = " | ".join(
|
|
7934
|
+
f"{self._format_value(row.get(key)):<12}" for key, _ in cols
|
|
7935
|
+
)
|
|
7908
7936
|
print(values)
|
|
7909
7937
|
|
|
7910
7938
|
elif isinstance(data, dict):
|
|
@@ -7917,12 +7945,16 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
7917
7945
|
items = items[:limit]
|
|
7918
7946
|
|
|
7919
7947
|
if items:
|
|
7920
|
-
cols = columns or
|
|
7921
|
-
|
|
7948
|
+
cols = self._normalize_columns(columns) or [
|
|
7949
|
+
(k, k) for k in list(items[0][1].keys())[:6]
|
|
7950
|
+
]
|
|
7951
|
+
header = f"{'Key':<12} | " + " | ".join(f"{label:<12}" for _, label in cols)
|
|
7922
7952
|
print(header)
|
|
7923
7953
|
print("-" * 80)
|
|
7924
7954
|
for key, val in items:
|
|
7925
|
-
values = f"{key:<12} | " + " | ".join(
|
|
7955
|
+
values = f"{key:<12} | " + " | ".join(
|
|
7956
|
+
f"{self._format_value(val.get(col_key)):<12}" for col_key, _ in cols
|
|
7957
|
+
)
|
|
7926
7958
|
print(values)
|
|
7927
7959
|
else:
|
|
7928
7960
|
# flat dict
|
|
@@ -8416,6 +8448,7 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
8416
8448
|
) -> Dict[str, Any]:
|
|
8417
8449
|
from programgarden.plugin import PluginSandbox, PluginTimeoutError
|
|
8418
8450
|
from programgarden_core.models import get_plugin_hints
|
|
8451
|
+
from programgarden_core.exceptions import ValidationError, ExecutionError
|
|
8419
8452
|
|
|
8420
8453
|
# 플러그인 ID 추출
|
|
8421
8454
|
plugin_id = config.get("plugin", "Unknown")
|
|
@@ -8430,15 +8463,14 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
8430
8463
|
)
|
|
8431
8464
|
|
|
8432
8465
|
if not resource_check["can_proceed"]:
|
|
8433
|
-
|
|
8434
|
-
#
|
|
8435
|
-
|
|
8436
|
-
|
|
8437
|
-
"
|
|
8438
|
-
"
|
|
8439
|
-
|
|
8440
|
-
|
|
8441
|
-
}
|
|
8466
|
+
# 하드 실패(리소스 고갈로 실행 불가) → raise. 빈 결과를 성공으로 위장하면
|
|
8467
|
+
# 하류 IfNode 가 is_condition_met=False 를 조용히 먹어 잘못된 분기를 탄다.
|
|
8468
|
+
context.log("error", f"Resource limit reached: {resource_check['reason']}", node_id)
|
|
8469
|
+
raise ExecutionError(
|
|
8470
|
+
f"ConditionNode cannot run — resource limit reached: "
|
|
8471
|
+
f"{resource_check['reason']}",
|
|
8472
|
+
node_id=node_id,
|
|
8473
|
+
)
|
|
8442
8474
|
|
|
8443
8475
|
# 권장 배치 크기 저장 (백테스트 모드에서 사용)
|
|
8444
8476
|
recommended_batch_size = resource_check.get("recommended_batch_size", 10)
|
|
@@ -8482,18 +8514,22 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
8482
8514
|
context.log("debug", f"positions 평가 후: {type(positions).__name__}, {len(positions) if isinstance(positions, (list, dict)) else 0} items", node_id)
|
|
8483
8515
|
|
|
8484
8516
|
if not positions or not isinstance(positions, list):
|
|
8485
|
-
|
|
8486
|
-
|
|
8487
|
-
|
|
8517
|
+
# 정상 0건: 보유 포지션이 없으면(빈 리스트) 검사할 대상이 없어 신호 0건.
|
|
8518
|
+
# `positions` 바인딩이 아예 없는 config 오류는 static validate 가
|
|
8519
|
+
# (resolver._validate: position-plugin ↔ positions) 이미 잡으므로,
|
|
8520
|
+
# 런타임 빈/미해결은 하드 실패로 죽이지 않고 정상 빈 값으로 흘린다.
|
|
8521
|
+
context.log("info",
|
|
8522
|
+
f"ConditionNode '{node_id}': positions 비어있음 → 통과 종목 없음(정상 0건).",
|
|
8488
8523
|
node_id
|
|
8489
8524
|
)
|
|
8490
8525
|
return {
|
|
8526
|
+
"symbols": [],
|
|
8491
8527
|
"result": False,
|
|
8528
|
+
"is_condition_met": False,
|
|
8492
8529
|
"passed_symbols": [],
|
|
8493
8530
|
"failed_symbols": [],
|
|
8531
|
+
"symbol_results": [],
|
|
8494
8532
|
"values": [],
|
|
8495
|
-
"error": "missing_positions",
|
|
8496
|
-
"error_message": "positions가 설정되지 않았습니다. 예: {{ nodes.realAccount.positions }}",
|
|
8497
8533
|
}
|
|
8498
8534
|
|
|
8499
8535
|
# fields 표현식 평가
|
|
@@ -8542,28 +8578,28 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
8542
8578
|
return {
|
|
8543
8579
|
"symbols": [p.get("symbol") for p in positions if isinstance(p, dict)],
|
|
8544
8580
|
"result": True if (getattr(context, "is_deep_validate", False) and _passed) else result.get("result", False),
|
|
8581
|
+
"is_condition_met": True if (getattr(context, "is_deep_validate", False) and _passed) else result.get("result", False),
|
|
8545
8582
|
"passed_symbols": _passed,
|
|
8546
8583
|
"failed_symbols": [] if (getattr(context, "is_deep_validate", False) and _passed) else result.get("failed_symbols", []),
|
|
8547
8584
|
"symbol_results": result.get("symbol_results", []),
|
|
8548
8585
|
"values": result.get("values", []),
|
|
8549
8586
|
}
|
|
8550
8587
|
except Exception as e:
|
|
8588
|
+
# 하드 실패(플러그인 실행 예외) → raise.
|
|
8551
8589
|
context.log("error", f"Plugin error: {e}", node_id)
|
|
8552
8590
|
import traceback
|
|
8553
8591
|
context.log("debug", f"Plugin traceback: {traceback.format_exc()}", node_id)
|
|
8554
|
-
|
|
8555
|
-
"
|
|
8556
|
-
|
|
8557
|
-
|
|
8558
|
-
"values": [],
|
|
8559
|
-
"error": str(e),
|
|
8560
|
-
}
|
|
8592
|
+
raise ExecutionError(
|
|
8593
|
+
f"ConditionNode plugin execution failed: {e}",
|
|
8594
|
+
node_id=node_id,
|
|
8595
|
+
) from e
|
|
8561
8596
|
else:
|
|
8562
8597
|
# 플러그인 없으면 모두 통과
|
|
8563
8598
|
passed_symbols = [{"symbol": s, "exchange": "UNKNOWN"} for s in positions.keys()]
|
|
8564
8599
|
return {
|
|
8565
8600
|
"symbols": list(positions.keys()),
|
|
8566
8601
|
"result": True,
|
|
8602
|
+
"is_condition_met": True,
|
|
8567
8603
|
"passed_symbols": passed_symbols,
|
|
8568
8604
|
"failed_symbols": [],
|
|
8569
8605
|
"symbol_results": [],
|
|
@@ -8580,14 +8616,13 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
8580
8616
|
f"items {{ from, extract }} 형태로 추가하세요.",
|
|
8581
8617
|
node_id
|
|
8582
8618
|
)
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
"
|
|
8586
|
-
"
|
|
8587
|
-
"
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
}
|
|
8619
|
+
# 하드 실패(필수입력 부재) → raise.
|
|
8620
|
+
raise ValidationError(
|
|
8621
|
+
"ConditionNode uses an indicator plugin but has no `items` binding. Add "
|
|
8622
|
+
"items { from, extract } — e.g. items: { from: '{{ nodes.hist.values }}', "
|
|
8623
|
+
"extract: { symbol: '{{ item.symbol }}', close: '{{ row.close }}' } }.",
|
|
8624
|
+
node_id=node_id,
|
|
8625
|
+
)
|
|
8591
8626
|
|
|
8592
8627
|
# === 플러그인 required_fields 검증 ===
|
|
8593
8628
|
if plugin_schema and hasattr(plugin_schema, 'required_fields'):
|
|
@@ -8600,30 +8635,33 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
8600
8635
|
f"플러그인 {plugin_id}는 {required_fields} 필드가 필요합니다.",
|
|
8601
8636
|
node_id
|
|
8602
8637
|
)
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
"
|
|
8606
|
-
"
|
|
8607
|
-
"
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
}
|
|
8638
|
+
# 하드 실패(필수입력 부재 — 플러그인 required_fields 누락) → raise.
|
|
8639
|
+
raise ValidationError(
|
|
8640
|
+
f"ConditionNode plugin '{plugin_id}' requires extract fields "
|
|
8641
|
+
f"{required_fields}, but these are missing: {missing}. Add them to "
|
|
8642
|
+
f"items.extract.",
|
|
8643
|
+
node_id=node_id,
|
|
8644
|
+
)
|
|
8611
8645
|
|
|
8612
8646
|
# items 처리 (from 배열을 순회하며 extract 적용)
|
|
8613
8647
|
data = self._process_items_with_extract(items_config, context, node_id)
|
|
8614
8648
|
|
|
8615
8649
|
if not data:
|
|
8616
|
-
|
|
8617
|
-
|
|
8650
|
+
# 정상 0건(런타임에 from 배열이 비어 평가할 항목 없음 — 예: 장 마감/무거래).
|
|
8651
|
+
# 하드 실패가 아니므로 선언 스키마 그대로 빈 값(no signal). error 키 없음.
|
|
8652
|
+
# 구조적 미배선은 B′ SplitNode/도달성 가드·deep_validate 가 별도로 잡는다.
|
|
8653
|
+
context.log("info",
|
|
8654
|
+
f"ConditionNode '{node_id}': items 처리 결과가 비어있어 통과 종목 없음(정상 0건).",
|
|
8618
8655
|
node_id
|
|
8619
8656
|
)
|
|
8620
8657
|
return {
|
|
8658
|
+
"symbols": [],
|
|
8621
8659
|
"result": False,
|
|
8660
|
+
"is_condition_met": False,
|
|
8622
8661
|
"passed_symbols": [],
|
|
8623
8662
|
"failed_symbols": [],
|
|
8663
|
+
"symbol_results": [],
|
|
8624
8664
|
"values": [],
|
|
8625
|
-
"error": "empty_items_result",
|
|
8626
|
-
"error_message": "items 처리 결과가 비어있습니다. from 배열을 확인하세요.",
|
|
8627
8665
|
}
|
|
8628
8666
|
|
|
8629
8667
|
# symbols 자동 추출 (data에서)
|
|
@@ -8674,14 +8712,13 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
8674
8712
|
field_mapping=field_mapping,
|
|
8675
8713
|
)
|
|
8676
8714
|
except PluginTimeoutError as e:
|
|
8715
|
+
# 하드 실패(플러그인 타임아웃) → raise.
|
|
8716
|
+
from programgarden_core.exceptions import ExecutionError
|
|
8677
8717
|
context.log("error", f"Plugin timeout: {e}", node_id)
|
|
8678
|
-
|
|
8679
|
-
"
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
"values": {},
|
|
8683
|
-
"error": "plugin_timeout",
|
|
8684
|
-
}
|
|
8718
|
+
raise ExecutionError(
|
|
8719
|
+
f"ConditionNode plugin timed out: {e}",
|
|
8720
|
+
node_id=node_id,
|
|
8721
|
+
) from e
|
|
8685
8722
|
finally:
|
|
8686
8723
|
# 리소스 반환
|
|
8687
8724
|
await context.release_resources_after_task(
|
|
@@ -10019,8 +10056,20 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
10019
10056
|
symbol_exchange_map[sym] = mc
|
|
10020
10057
|
|
|
10021
10058
|
if not symbols:
|
|
10059
|
+
# NOTE: 정상 return(아래 9690)과 **동일 스키마**로 반환해야 한다. 옛
|
|
10060
|
+
# `{"ohlcv_data": {}, "symbols": []}` 는 정상 형태(value/values/period/
|
|
10061
|
+
# interval)와 완전히 달라, 하류의 정상 바인딩(`{{ nodes.x.value.time_series }}`
|
|
10062
|
+
# / ConditionNode 의 `item.time_series`)이 조용히 None 이 되고 방어적
|
|
10063
|
+
# `data or []` 가 그걸 삼켜 에러 없이 빈 결과(count:0)를 낸다.
|
|
10064
|
+
# (deep_fixtures.historical_data_fixture 도 이 스키마를 못박는다.)
|
|
10022
10065
|
context.log("warning", "No symbols provided", node_id)
|
|
10023
|
-
return {
|
|
10066
|
+
return {
|
|
10067
|
+
"value": None,
|
|
10068
|
+
"values": [],
|
|
10069
|
+
"symbols": [],
|
|
10070
|
+
"period": "",
|
|
10071
|
+
"interval": config.get("interval", "1d"),
|
|
10072
|
+
}
|
|
10024
10073
|
|
|
10025
10074
|
# 기간 설정 ({{ today_yyyymmdd() }}, {{ days_ago_yyyymmdd(100) }} 바인딩 사용)
|
|
10026
10075
|
start_date = config.get("start_date", "")
|
|
@@ -10685,14 +10734,15 @@ class BacktestEngineNodeExecutor(NodeExecutorBase):
|
|
|
10685
10734
|
)
|
|
10686
10735
|
|
|
10687
10736
|
if not resource_check["can_proceed"]:
|
|
10737
|
+
# 하드 실패(리소스 고갈로 실행 불가) → raise(빈 결과를 성공으로 위장하지 않음).
|
|
10738
|
+
from programgarden_core.exceptions import ExecutionError
|
|
10688
10739
|
context.log("error", f"Cannot run backtest: {resource_check['reason']}", node_id)
|
|
10689
|
-
|
|
10690
|
-
"
|
|
10691
|
-
"
|
|
10692
|
-
|
|
10693
|
-
|
|
10694
|
-
|
|
10695
|
-
|
|
10740
|
+
raise ExecutionError(
|
|
10741
|
+
f"BacktestEngineNode cannot run — resource limit reached: "
|
|
10742
|
+
f"{resource_check['reason']}",
|
|
10743
|
+
node_id=node_id,
|
|
10744
|
+
)
|
|
10745
|
+
|
|
10696
10746
|
try:
|
|
10697
10747
|
# === items { from, extract } 방식으로 입력 데이터 가져오기 ===
|
|
10698
10748
|
items_config = config.get("items")
|
|
@@ -10709,6 +10759,8 @@ class BacktestEngineNodeExecutor(NodeExecutorBase):
|
|
|
10709
10759
|
return {
|
|
10710
10760
|
"equity_curve": [],
|
|
10711
10761
|
"trades": [],
|
|
10762
|
+
"signals": [],
|
|
10763
|
+
"values": [],
|
|
10712
10764
|
"metrics": {},
|
|
10713
10765
|
"summary": {"error": "items 처리 결과가 비어있습니다."},
|
|
10714
10766
|
}
|
|
@@ -11695,6 +11747,7 @@ class PortfolioNodeExecutor(NodeExecutorBase):
|
|
|
11695
11747
|
"rebalance_signal": False,
|
|
11696
11748
|
"rebalance_orders": [],
|
|
11697
11749
|
"allocated_capital": {},
|
|
11750
|
+
"drawdown_percent": 0,
|
|
11698
11751
|
}
|
|
11699
11752
|
|
|
11700
11753
|
|
|
@@ -14165,16 +14218,14 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
|
|
|
14165
14218
|
}
|
|
14166
14219
|
|
|
14167
14220
|
except Exception as e:
|
|
14168
|
-
|
|
14169
|
-
|
|
14170
|
-
|
|
14171
|
-
|
|
14172
|
-
|
|
14173
|
-
|
|
14174
|
-
|
|
14175
|
-
|
|
14176
|
-
"modified_order": None,
|
|
14177
|
-
}
|
|
14221
|
+
# 하드 실패 → raise(에러-dict 로 삼키면 하류가 침묵의 None → silent garbage).
|
|
14222
|
+
from programgarden_core.exceptions import ExecutionError
|
|
14223
|
+
context.log("error", f"Modify order exception (overseas_stock): {e}", node_id)
|
|
14224
|
+
raise ExecutionError(
|
|
14225
|
+
f"ModifyOrderNode failed to modify overseas_stock order "
|
|
14226
|
+
f"'{original_order_id}': {e}",
|
|
14227
|
+
node_id=node_id,
|
|
14228
|
+
) from e
|
|
14178
14229
|
|
|
14179
14230
|
async def _modify_overseas_futures(
|
|
14180
14231
|
self,
|
|
@@ -14287,16 +14338,13 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
|
|
|
14287
14338
|
}
|
|
14288
14339
|
|
|
14289
14340
|
except Exception as e:
|
|
14290
|
-
|
|
14291
|
-
|
|
14292
|
-
|
|
14293
|
-
|
|
14294
|
-
|
|
14295
|
-
|
|
14296
|
-
|
|
14297
|
-
},
|
|
14298
|
-
"modified_order": None,
|
|
14299
|
-
}
|
|
14341
|
+
from programgarden_core.exceptions import ExecutionError
|
|
14342
|
+
context.log("error", f"Modify futures order exception: {e}", node_id)
|
|
14343
|
+
raise ExecutionError(
|
|
14344
|
+
f"ModifyOrderNode failed to modify overseas_futures order "
|
|
14345
|
+
f"'{original_order_id}': {e}",
|
|
14346
|
+
node_id=node_id,
|
|
14347
|
+
) from e
|
|
14300
14348
|
|
|
14301
14349
|
async def _modify_korea_stock(
|
|
14302
14350
|
self,
|
|
@@ -14389,16 +14437,13 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
|
|
|
14389
14437
|
}
|
|
14390
14438
|
|
|
14391
14439
|
except Exception as e:
|
|
14392
|
-
|
|
14393
|
-
|
|
14394
|
-
|
|
14395
|
-
|
|
14396
|
-
|
|
14397
|
-
|
|
14398
|
-
|
|
14399
|
-
},
|
|
14400
|
-
"modified_order": None,
|
|
14401
|
-
}
|
|
14440
|
+
from programgarden_core.exceptions import ExecutionError
|
|
14441
|
+
context.log("error", f"Korea stock modify order exception: {e}", node_id)
|
|
14442
|
+
raise ExecutionError(
|
|
14443
|
+
f"ModifyOrderNode failed to modify korea_stock order "
|
|
14444
|
+
f"'{original_order_id}': {e}",
|
|
14445
|
+
node_id=node_id,
|
|
14446
|
+
) from e
|
|
14402
14447
|
|
|
14403
14448
|
def _error_result(self, error_msg: str) -> Dict[str, Any]:
|
|
14404
14449
|
"""에러 결과 반환"""
|
|
@@ -14456,7 +14501,9 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
|
|
|
14456
14501
|
)
|
|
14457
14502
|
return {
|
|
14458
14503
|
"order_id": order_id,
|
|
14504
|
+
"cancel_result": None,
|
|
14459
14505
|
"cancelled_order_id": order_id,
|
|
14506
|
+
"cancelled_order": None,
|
|
14460
14507
|
"status": "simulated",
|
|
14461
14508
|
"dry_run": True,
|
|
14462
14509
|
"requested": config,
|
|
@@ -15346,13 +15393,16 @@ class AIAgentNodeExecutor(NodeExecutorBase):
|
|
|
15346
15393
|
) -> Dict[str, Any]:
|
|
15347
15394
|
import json as json_module
|
|
15348
15395
|
from programgarden.providers import LLMProvider
|
|
15396
|
+
from programgarden_core.exceptions import ValidationError, ExecutionError
|
|
15349
15397
|
|
|
15350
|
-
# === 0. deep_validate: 실 LLM/ReAct 루프를 절대 돌리지 않는다 ===
|
|
15398
|
+
# === 0. deep_validate / dry_run: 실 LLM/ReAct 루프를 절대 돌리지 않는다 ===
|
|
15351
15399
|
# 가짜(스키마-shaped) response 를 주입해 다운스트림 {{ nodes.X.response[.field] }}
|
|
15352
|
-
# 바인딩이 풀리고 flow 무결성이 검증되도록 한다(네트워크·모델비용 0).
|
|
15353
|
-
#
|
|
15400
|
+
# 바인딩이 풀리고 flow 무결성이 검증되도록 한다(네트워크·모델비용 0).
|
|
15401
|
+
# dry_run 도 시뮬레이션 모드다(주문 노드가 dry_run 에서 시뮬레이션하는 것과 동일).
|
|
15402
|
+
# 실 LLM 호출 실패는 아래 실경로에서 **raise** 로 시끄럽게 처리하고(silent None 금지),
|
|
15403
|
+
# 시뮬레이션 모드에선 실 호출 자체를 안 하므로 여기서 fixture 로 단락한다.
|
|
15354
15404
|
# preset 을 먼저 적용해 output_format/output_schema 가 런타임과 동일하게 반영되도록 함.
|
|
15355
|
-
if getattr(context, "is_deep_validate", False):
|
|
15405
|
+
if getattr(context, "is_deep_validate", False) or getattr(context, "is_dry_run", False):
|
|
15356
15406
|
from programgarden import deep_fixtures as _df
|
|
15357
15407
|
_deep_config = config
|
|
15358
15408
|
_preset_id = config.get("preset")
|
|
@@ -15376,12 +15426,21 @@ class AIAgentNodeExecutor(NodeExecutorBase):
|
|
|
15376
15426
|
workflow = kwargs.get("workflow")
|
|
15377
15427
|
if not workflow:
|
|
15378
15428
|
context.log("error", "AIAgentNode requires workflow context", node_id)
|
|
15379
|
-
|
|
15429
|
+
# 하드 실패(설정 오류) → raise. 에러-dict 로 굴러가면 하류가 침묵의 None 을
|
|
15430
|
+
# 먹고 워크플로우가 완주해 silent garbage 가 된다(§sweep 리워크).
|
|
15431
|
+
raise ValidationError(
|
|
15432
|
+
"AIAgentNode requires workflow context but none was provided.",
|
|
15433
|
+
node_id=node_id,
|
|
15434
|
+
)
|
|
15380
15435
|
|
|
15381
15436
|
ai_model_node_id = workflow.get_ai_model_node_id(node_id)
|
|
15382
15437
|
if not ai_model_node_id:
|
|
15383
15438
|
context.log("error", "No LLMModelNode connected via ai_model edge", node_id)
|
|
15384
|
-
|
|
15439
|
+
raise ValidationError(
|
|
15440
|
+
"No LLM model connected. Connect a LLMModelNode to this AIAgentNode via an "
|
|
15441
|
+
"ai_model edge.",
|
|
15442
|
+
node_id=node_id,
|
|
15443
|
+
)
|
|
15385
15444
|
|
|
15386
15445
|
# LLMModelNode의 출력에서 connection 가져오기
|
|
15387
15446
|
llm_connection = context.get_output(ai_model_node_id, "connection")
|
|
@@ -15401,7 +15460,11 @@ class AIAgentNodeExecutor(NodeExecutorBase):
|
|
|
15401
15460
|
|
|
15402
15461
|
if not llm_connection:
|
|
15403
15462
|
context.log("error", "Failed to get LLM connection", node_id)
|
|
15404
|
-
|
|
15463
|
+
raise ValidationError(
|
|
15464
|
+
"Failed to get LLM connection from the connected LLMModelNode "
|
|
15465
|
+
"(check the model node's config/credentials).",
|
|
15466
|
+
node_id=node_id,
|
|
15467
|
+
)
|
|
15405
15468
|
|
|
15406
15469
|
# secrets에서 API 키 복원 (H-8: 평문 노출 방지)
|
|
15407
15470
|
llm_node_ref = llm_connection.get("_llm_node_id", ai_model_node_id)
|
|
@@ -15508,7 +15571,7 @@ class AIAgentNodeExecutor(NodeExecutorBase):
|
|
|
15508
15571
|
)
|
|
15509
15572
|
except Exception as e:
|
|
15510
15573
|
context.log("error", f"LLM call failed: {e}", node_id)
|
|
15511
|
-
|
|
15574
|
+
raise ExecutionError(f"AIAgentNode LLM call failed: {e}", node_id=node_id) from e
|
|
15512
15575
|
|
|
15513
15576
|
# 토큰 사용량 이벤트
|
|
15514
15577
|
from programgarden_core.bases.listener import TokenUsageEvent
|
|
@@ -15629,7 +15692,11 @@ class AIAgentNodeExecutor(NodeExecutorBase):
|
|
|
15629
15692
|
))
|
|
15630
15693
|
|
|
15631
15694
|
if tool_error_strategy == "abort":
|
|
15632
|
-
|
|
15695
|
+
raise ExecutionError(
|
|
15696
|
+
f"AIAgentNode tool '{tool_name}' failed and tool_error_strategy="
|
|
15697
|
+
f"'abort': {e}",
|
|
15698
|
+
node_id=node_id,
|
|
15699
|
+
) from e
|
|
15633
15700
|
elif tool_error_strategy == "skip":
|
|
15634
15701
|
messages.append({
|
|
15635
15702
|
"role": "tool",
|
|
@@ -320,6 +320,12 @@ class WorkflowResolver:
|
|
|
320
320
|
# 10.5 CodeNode 정적 검증 (compile/screen 사전검증 + credential 봉쇄)
|
|
321
321
|
self._validate_code_nodes(workflow, result)
|
|
322
322
|
|
|
323
|
+
# 10.6 SplitNode 소스 검증 (분리할 배열이 실제로 배선됐는지)
|
|
324
|
+
self._validate_split_nodes(workflow, registry, result)
|
|
325
|
+
|
|
326
|
+
# 10.7 AIAgentNode ai_model 엣지 필수 검증 (LLM 없이는 애초에 동작 불가)
|
|
327
|
+
self._validate_ai_agent_nodes(workflow, result)
|
|
328
|
+
|
|
323
329
|
# 11. Static recommendations (topology analysis)
|
|
324
330
|
for rec in run_static_recommendation_rules(
|
|
325
331
|
workflow,
|
|
@@ -1085,6 +1091,194 @@ class WorkflowResolver:
|
|
|
1085
1091
|
)
|
|
1086
1092
|
)
|
|
1087
1093
|
|
|
1094
|
+
# SplitNode 는 실 엔진에서 두 조건이 **모두** 충족돼야만 동작한다(6변형 dry_run
|
|
1095
|
+
# 프로브로 확정, executor._execute_split_branch / _execute_main_flow):
|
|
1096
|
+
# (1) 짝 AggregateNode 가 그래프상 도달 가능해야 한다. 없으면 _execute_split_branch
|
|
1097
|
+
# 가 aggregate_id=None 에서 즉시 return → branch 자체가 실행 안 됨(노드 pending
|
|
1098
|
+
# 유지, 하류 item 전부 공백). engine._find_split_aggregate_pairs 참조.
|
|
1099
|
+
# (2) split 로 들어오는 상류 노드가 리스트를 출력해야 한다. 엔진은 분리 대상 배열을
|
|
1100
|
+
# **오직 상류 노드 출력**(포트 symbols/values/array/data/items)에서만 읽는다.
|
|
1101
|
+
# config `array` 필드는 **어느 경로에서도 읽히지 않는다**(SplitNodeExecutor.execute
|
|
1102
|
+
# 의 config.get("array") 는 죽은 코드 — 실행 엔진이 그 executor 를 거치지 않고
|
|
1103
|
+
# 상류 출력에서 배열을 직접 가져와 item 을 set). 따라서 `array`/`items` 를 config
|
|
1104
|
+
# 리터럴로 넣는 저작 패턴은 검증만 통과하고 런타임엔 0개를 낸다.
|
|
1105
|
+
# 둘 중 하나라도 빠지면 하류 `{{ nodes.<split>.item }}` 이 조용히 비어 count:0 쓰레기
|
|
1106
|
+
# 결과가 된다(단일 심볼에 SplitNode 를 잘못 붙이는 것이 전형적 저작 결함). 정적으로
|
|
1107
|
+
# 잡아 AI self-correct 루프가 dry_run 전에 보게 한다.
|
|
1108
|
+
# 오탐(최대 리스크) 최소화: (2)는 상류 중 하나라도 리스트를 낼 "가능성"이 있으면 통과.
|
|
1109
|
+
# 가능성 = (a) 상류가 CodeNode(동적 출력), (b) 상류 스키마 미상(커뮤니티/제네릭),
|
|
1110
|
+
# (c) 상류 출력 포트 타입 중 하나라도 확정 스칼라/시그널이 아님.
|
|
1111
|
+
# 모든 상류가 확정 스칼라일 때만 flag.
|
|
1112
|
+
_SPLIT_SCALAR_OUTPUT_TYPES = frozenset({
|
|
1113
|
+
"signal", "boolean", "integer", "number", "float", "string",
|
|
1114
|
+
"broker_connection",
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
def _validate_split_nodes(self, workflow, registry, result: ValidationResult) -> None:
|
|
1118
|
+
"""SplitNode 가 실 엔진에서 동작 가능하게 배선됐는지 정적 검증(오탐 보수적).
|
|
1119
|
+
|
|
1120
|
+
엔진 동작(프로브 확정): SplitNode 는 (1) 짝 AggregateNode 가 도달 가능하고
|
|
1121
|
+
(2) 상류가 리스트를 출력해야만 branch 를 실행한다. config `array` 는 안 읽힌다.
|
|
1122
|
+
"""
|
|
1123
|
+
split_nodes = [
|
|
1124
|
+
n for n in workflow.nodes
|
|
1125
|
+
if isinstance(n, dict) and n.get("type") == "SplitNode"
|
|
1126
|
+
]
|
|
1127
|
+
if not split_nodes:
|
|
1128
|
+
return
|
|
1129
|
+
node_type_by_id = {
|
|
1130
|
+
n.get("id"): n.get("type") for n in workflow.nodes if isinstance(n, dict)
|
|
1131
|
+
}
|
|
1132
|
+
aggregate_ids: Set[str] = {
|
|
1133
|
+
n.get("id") for n in workflow.nodes
|
|
1134
|
+
if isinstance(n, dict) and n.get("type") == "AggregateNode"
|
|
1135
|
+
}
|
|
1136
|
+
# adjacency(from_id -> [to_id]) + incoming(to_id -> [from_id])
|
|
1137
|
+
adjacency: Dict[str, List[str]] = {}
|
|
1138
|
+
incoming: Dict[str, List[str]] = {}
|
|
1139
|
+
for edge in workflow.edges:
|
|
1140
|
+
to_raw = getattr(edge, "to_node", "") or ""
|
|
1141
|
+
from_raw = getattr(edge, "from_node", "") or ""
|
|
1142
|
+
to_id = to_raw.split(".")[0] if to_raw else ""
|
|
1143
|
+
from_id = from_raw.split(".")[0] if from_raw else ""
|
|
1144
|
+
if to_id:
|
|
1145
|
+
incoming.setdefault(to_id, []).append(from_id)
|
|
1146
|
+
if from_id and to_id:
|
|
1147
|
+
adjacency.setdefault(from_id, []).append(to_id)
|
|
1148
|
+
|
|
1149
|
+
def _reaches_aggregate(start: Optional[str]) -> bool:
|
|
1150
|
+
# 엔진 _find_split_aggregate_pairs 와 동일하게 그래프 도달성으로 짝을 판정.
|
|
1151
|
+
if not start:
|
|
1152
|
+
return False
|
|
1153
|
+
seen: Set[str] = set()
|
|
1154
|
+
queue: List[str] = [start]
|
|
1155
|
+
while queue:
|
|
1156
|
+
cur = queue.pop(0)
|
|
1157
|
+
if cur in seen:
|
|
1158
|
+
continue
|
|
1159
|
+
seen.add(cur)
|
|
1160
|
+
for nxt in adjacency.get(cur, []):
|
|
1161
|
+
if nxt in aggregate_ids:
|
|
1162
|
+
return True
|
|
1163
|
+
queue.append(nxt)
|
|
1164
|
+
return False
|
|
1165
|
+
|
|
1166
|
+
def _may_produce_list(src_type: Optional[str]) -> bool:
|
|
1167
|
+
# 동적/미상 출력은 리스트 가능 → 보수적으로 통과.
|
|
1168
|
+
if not src_type or src_type == "CodeNode":
|
|
1169
|
+
return True
|
|
1170
|
+
schema = registry.get_schema(src_type)
|
|
1171
|
+
if schema is None:
|
|
1172
|
+
return True
|
|
1173
|
+
ports = list(schema.outputs or [])
|
|
1174
|
+
if not ports:
|
|
1175
|
+
return True # 출력 미선언 → 알 수 없음 → 통과
|
|
1176
|
+
for out in ports:
|
|
1177
|
+
ptype = out.get("type") if isinstance(out, dict) else getattr(out, "type", None)
|
|
1178
|
+
if ptype not in self._SPLIT_SCALAR_OUTPUT_TYPES:
|
|
1179
|
+
return True # 배열/오브젝트 등 비스칼라 포트 존재 → 가능성 있음
|
|
1180
|
+
return False # 모든 포트가 확정 스칼라 → 리스트 생산 불가
|
|
1181
|
+
|
|
1182
|
+
for node in split_nodes:
|
|
1183
|
+
sid = node.get("id")
|
|
1184
|
+
# (1) 짝 AggregateNode 도달성 — 없으면 엔진이 branch 를 실행조차 안 한다.
|
|
1185
|
+
if not _reaches_aggregate(sid):
|
|
1186
|
+
result.add(
|
|
1187
|
+
build_error(
|
|
1188
|
+
ErrorCode.MISSING_REQUIRED_FIELD,
|
|
1189
|
+
(
|
|
1190
|
+
f"SplitNode '{sid}' has no reachable AggregateNode. The engine "
|
|
1191
|
+
f"only runs a SplitNode's per-item branch when a paired "
|
|
1192
|
+
f"AggregateNode is reachable from it — without one the branch "
|
|
1193
|
+
f"never executes (the node stays pending) and every downstream "
|
|
1194
|
+
f"`{{{{ nodes.{sid}.item }}}}` resolves empty (silent count:0)."
|
|
1195
|
+
),
|
|
1196
|
+
location=ErrorLocation(node_id=sid, node_type="SplitNode", field_path=None),
|
|
1197
|
+
suggestion=(
|
|
1198
|
+
"Add an AggregateNode downstream of this split's branch (so it is "
|
|
1199
|
+
"reachable from the SplitNode), which recollects the per-item "
|
|
1200
|
+
"results. If you only ever have a single value, DELETE the "
|
|
1201
|
+
"SplitNode and bind that value directly on the downstream node."
|
|
1202
|
+
),
|
|
1203
|
+
)
|
|
1204
|
+
)
|
|
1205
|
+
continue
|
|
1206
|
+
# (2) 상류 리스트 소스 — config `array` 는 엔진이 안 읽으므로 소스로 인정 안 함.
|
|
1207
|
+
srcs = incoming.get(sid, [])
|
|
1208
|
+
if srcs and any(_may_produce_list(node_type_by_id.get(s)) for s in srcs):
|
|
1209
|
+
continue
|
|
1210
|
+
result.add(
|
|
1211
|
+
build_error(
|
|
1212
|
+
ErrorCode.MISSING_REQUIRED_FIELD,
|
|
1213
|
+
(
|
|
1214
|
+
f"SplitNode '{sid}' has no upstream node that outputs a list. The "
|
|
1215
|
+
f"engine reads the array to split ONLY from an upstream node's list "
|
|
1216
|
+
f"output (ports symbols/values/array/data/items) — the `array` "
|
|
1217
|
+
f"config field is NOT read at runtime. It will emit zero items and "
|
|
1218
|
+
f"every downstream `{{{{ nodes.{sid}.item }}}}` resolves empty "
|
|
1219
|
+
f"(silent count:0 result)."
|
|
1220
|
+
),
|
|
1221
|
+
location=ErrorLocation(node_id=sid, node_type="SplitNode", field_path="array"),
|
|
1222
|
+
suggestion=(
|
|
1223
|
+
"Connect an edge from an upstream node that OUTPUTS a list "
|
|
1224
|
+
"(WatchlistNode/MarketUniverseNode → symbols, AccountNode → "
|
|
1225
|
+
"positions, ConditionNode → values, or a CodeNode returning a list). "
|
|
1226
|
+
"Do NOT put the list in an `array`/`items` config field — the engine "
|
|
1227
|
+
"ignores it. For a single known symbol, DELETE SplitNode and bind "
|
|
1228
|
+
"that symbol directly on the downstream node."
|
|
1229
|
+
),
|
|
1230
|
+
)
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
def _validate_ai_agent_nodes(self, workflow, result: ValidationResult) -> None:
|
|
1234
|
+
"""AIAgentNode 는 ai_model 엣지로 LLMModelNode 가 연결돼야 한다.
|
|
1235
|
+
|
|
1236
|
+
LLM 이 없으면 이 노드는 **애초에 동작 불가**(런타임에 raise)이므로 오탐 위험이
|
|
1237
|
+
0이다. static validate 에서 잡아 챗봇이 저장 전에 고치게 한다 — deep_validate 는
|
|
1238
|
+
인프라 오류 시 검증을 건너뛰고 '성공'으로 저장할 수 있어 못 믿는다(soft-skip).
|
|
1239
|
+
ai_model 엣지의 source/target **형태** 오류는 _validate_edge_references 가
|
|
1240
|
+
(INVALID_AI_MODEL_EDGE) 별도로 잡으므로, 여기선 '연결 자체가 없는' 구멍만 본다.
|
|
1241
|
+
"""
|
|
1242
|
+
from programgarden_core.models.edge import EdgeType
|
|
1243
|
+
|
|
1244
|
+
agent_ids = [
|
|
1245
|
+
n.get("id")
|
|
1246
|
+
for n in workflow.nodes
|
|
1247
|
+
if isinstance(n, dict) and n.get("type") == "AIAgentNode"
|
|
1248
|
+
]
|
|
1249
|
+
if not agent_ids:
|
|
1250
|
+
return
|
|
1251
|
+
# ai_model 엣지가 타깃으로 삼는 AIAgentNode id 집합
|
|
1252
|
+
ai_model_targets: Set[str] = set()
|
|
1253
|
+
for edge in workflow.edges:
|
|
1254
|
+
edge_type = getattr(edge, "edge_type", None)
|
|
1255
|
+
et = (
|
|
1256
|
+
edge_type.value if hasattr(edge_type, "value")
|
|
1257
|
+
else (str(edge_type) if edge_type else "main")
|
|
1258
|
+
)
|
|
1259
|
+
if et != EdgeType.AI_MODEL.value:
|
|
1260
|
+
continue
|
|
1261
|
+
to_raw = getattr(edge, "to_node", "") or ""
|
|
1262
|
+
to_id = to_raw.split(".")[0] if to_raw else ""
|
|
1263
|
+
if to_id:
|
|
1264
|
+
ai_model_targets.add(to_id)
|
|
1265
|
+
|
|
1266
|
+
for aid in agent_ids:
|
|
1267
|
+
if aid in ai_model_targets:
|
|
1268
|
+
continue
|
|
1269
|
+
result.add(
|
|
1270
|
+
build_error(
|
|
1271
|
+
ErrorCode.MISSING_REQUIRED_FIELD,
|
|
1272
|
+
f"AIAgentNode '{aid}' has no LLM model connected — it cannot run without "
|
|
1273
|
+
f"one. Connect an LLMModelNode to '{aid}' via an \"ai_model\" edge.",
|
|
1274
|
+
location=ErrorLocation(node_id=aid, node_type="AIAgentNode", field_path="ai_model"),
|
|
1275
|
+
suggestion=(
|
|
1276
|
+
"Add an LLMModelNode and wire it with an ai_model edge, e.g. "
|
|
1277
|
+
f'{{"from": "<llm_node_id>", "to": "{aid}", "edge_type": "ai_model"}}.'
|
|
1278
|
+
),
|
|
1279
|
+
)
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1088
1282
|
def _validate_credential_references(
|
|
1089
1283
|
self,
|
|
1090
1284
|
workflow,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|