Coverage for core/langgraph/discover.py: 0%

78 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-08 05:24 +0000

1""" 

2discover.py — dynaaminen pipeline-lataaja. 

3 

4Käynnistyksessä: 

5 1. Skannaa /project/.agent-platform/*-pipeline.py (legacy, backward-compat) 

6 2. Skannaa /project/.agent-platform/**/*.yaml (uusi manifest-pohjainen) 

7 3. Generoi /tmp/langgraph.json (graph-nimet tiedostonimistä / manifest-nimistä) 

8 4. Tulostaa löydetyt pipelinet 

9 

10YAML-manifest-pipelinet: 

11 - Löydetyt Pipeline-manifestit rekisteröidään nimellä metadata.name 

12 - Graph-polku osoittaa runner.py:hin: /project/langgraph/runner.py:workflow_<name> 

13 - runner.py:ssä on thin wrapper workflow_<name> per pipeline 

14 - Dynaaminen registry: /tmp/pipeline_registry.json 

15 

16Legacy-pipelinet (*-pipeline.py): 

17 - Toimivat edelleen kuten ennen (backward-compatible) 

18 

19Graphin nimi: metadata.name (YAML) tai tiedostonimi ilman "-pipeline.py". 

20Tukee PostgreSQL-storea (LANGGRAPH_STORE_POSTGRES_URI env-var). 

21""" 

22 

23import json 

24import os 

25import sys 

26from pathlib import Path 

27 

28PIPELINE_DIR = Path(os.getenv("PROJECT_ROOT", "/project")) / ".agent-platform" 

29PIPELINE_PATTERN = "*-pipeline.py" 

30MANIFEST_PATTERN = "**/*.yaml" 

31OUTPUT = Path("/tmp/langgraph.json") 

32REGISTRY_OUTPUT = Path("/tmp/pipeline_registry.json") 

33STORE_URI = os.getenv("LANGGRAPH_STORE_POSTGRES_URI", "") 

34 

35# Runner.py sijaitsee samassa hakemistossa kuin discover.py 

36RUNNER_PATH = Path(__file__).parent / "runner.py" 

37RUNNER_MODULE_PATH = "/project/langgraph/runner.py" 

38 

39 

40def _load_yaml_pipelines(pipeline_dir: Path) -> list[dict]: 

41 """ 

42 Skannaa *.yaml manifestit ja palauttaa löydettyjen Pipeline-manifestien 

43 listan: [{name: str, source: str, budget: dict}]. 

44 Ei tarvita pyyaml:ia jos se ei ole asennettuna — graceful degradation. 

45 """ 

46 try: 

47 import yaml # noqa: PLC0415 

48 except ImportError: 

49 print("[discover] pyyaml not installed — skipping YAML manifest scan") 

50 return [] 

51 

52 pipelines: list[dict] = [] 

53 if not pipeline_dir.exists(): 

54 return pipelines 

55 

56 for yaml_path in sorted(pipeline_dir.glob(MANIFEST_PATTERN)): 

57 try: 

58 content = yaml_path.read_text(encoding="utf-8") 

59 except OSError: 

60 continue 

61 for raw in content.split("\n---"): 

62 raw_clean = "\n".join( 

63 line for line in raw.splitlines() 

64 if not line.strip().startswith("#") 

65 ) 

66 try: 

67 doc = yaml.safe_load(raw_clean) 

68 except yaml.YAMLError: 

69 continue 

70 if ( 

71 isinstance(doc, dict) 

72 and doc.get("apiVersion") == "agent-platform/v1" 

73 and doc.get("kind") == "Pipeline" 

74 ): 

75 name = (doc.get("metadata") or {}).get("name") 

76 if name: 

77 budget = (doc.get("spec") or {}).get("budget") or {} 

78 pipelines.append({ 

79 "name": name, 

80 "source": str(yaml_path), 

81 "budget": budget, 

82 }) 

83 print(f"[discover] YAML Pipeline '{name}' ← {yaml_path.name}") 

84 

85 return pipelines 

86 

87 

88def _generate_wrapper(name: str, source_yaml: str, budget: dict) -> str: 

89 """ 

90 Tuottaa thin-wrapperin /tmp/<name>-pipeline.py. 

91 

92 Wrapper: 

93 - lataa config.yaml 

94 - alustaa LangfuseTracer (per ajo, uuid-seed) 

95 - alustaa RunBudget pipeline-manifestin budget-asetuksista 

96 - alustaa OpenAIInvoker 

97 - kutsuu build_workflow(name, invoke_agent_fn=invoker, run_context={budget, config}) 

98 """ 

99 import json as _json 

100 # repr() tuottaa validia Python-dict-literalia (True/False/None) toisin 

101 # kuin json.dumps() joka tuottaa JSON-syntaksia (true/false/null). 

102 budget_literal = repr(budget) 

103 return f'''# Auto-generated by discover.py — do not edit 

104"""Per-pipeline wrapper using LangGraph factory pattern. 

105 

106LangGraph calls ``make_workflow(config)`` ONCE PER RUN, ensuring fresh 

107RunBudget / LangfuseTracer / OpenAIInvoker per invocation. This avoids the 

108singleton-state bug where module-level instances accumulated across runs 

109(exhausting the LLM-call budget after the first run). 

110 

111The factory is async because blockbuster (LangGraph local_dev) detects 

112blocking I/O on the event loop. All file/network work runs in a thread. 

113""" 

114import asyncio 

115import os 

116import sys 

117import uuid 

118 

119import yaml 

120 

121sys.path.insert(0, "/project/langgraph") 

122 

123from runner import build_workflow # noqa: E402 

124from _llm import OpenAIInvoker, LangfuseTracer, RunBudget # noqa: E402 

125 

126# --- Static (safe at module scope: no side effects, no mutable state) ----- 

127_PIPELINE_NAME = {name!r} 

128_BUDGET_SPEC = {budget_literal} 

129 

130 

131def _build_workflow_sync(): 

132 """All blocking work (file I/O, manifest load, tracer init) in one place.""" 

133 # Provider registry — K8s Job path sets LLM_CONFIGS env; dev/local path 

134 # passes an empty registry (no config.yaml). By ADR-0016 D6, K8s is the 

135 # only path; config.yaml is obsolete. 

136 import json as _json_mod 

137 _providers_raw = os.getenv("LLM_CONFIGS", "[]") 

138 try: 

139 _PROVIDERS = _json_mod.loads(_providers_raw) 

140 except _json_mod.JSONDecodeError: 

141 _PROVIDERS = [] 

142 

143 _prov_registry = {{}} 

144 for _c in _PROVIDERS: 

145 _n = _c.get("name") 

146 if _n and _n not in _prov_registry: 

147 _prov_registry[_n] = {{ 

148 "base_url": _c.get("base_url", ""), 

149 "model": _c.get("model", ""), 

150 "api_key": _c.get("api_key", ""), 

151 "temperature": float(_c.get("temperature", 0.0)), 

152 }} 

153 

154 max_parallel = int(_BUDGET_SPEC.get("max_parallel", 8)) 

155 

156 budget = RunBudget( 

157 max_llm_calls=int(_BUDGET_SPEC.get("max_llm_calls", 48)), 

158 run_timeout_seconds=int(_BUDGET_SPEC.get("timeout_seconds", 240)), 

159 ) 

160 

161 tracer = LangfuseTracer( 

162 project_slug=os.getenv("PROJECT_SLUG", "unknown"), 

163 pipeline_name=_PIPELINE_NAME, 

164 run_id=uuid.uuid4().hex, 

165 ) 

166 tracer.init_trace() 

167 

168 invoker = OpenAIInvoker( 

169 providers=_prov_registry, 

170 max_parallel=max_parallel, 

171 budget=budget, 

172 tracer=tracer, 

173 ) 

174 

175 run_context = {{ 

176 "budget": dict(_BUDGET_SPEC), 

177 "config": {{}}, 

178 }} 

179 

180 return build_workflow( 

181 _PIPELINE_NAME, 

182 invoke_agent_fn=invoker, 

183 run_context=run_context, 

184 ) 

185 

186 

187async def make_workflow(config): 

188 """Factory invoked by LangGraph per run. Builds fresh runtime objects.""" 

189 return await asyncio.to_thread(_build_workflow_sync) 

190''' 

191 

192 

193def main() -> None: 

194 graphs = {} 

195 

196 # --- Legacy: *-pipeline.py --- 

197 legacy_names: set[str] = set() 

198 if PIPELINE_DIR.exists(): 

199 for f in sorted(PIPELINE_DIR.glob(PIPELINE_PATTERN)): 

200 name = f.stem.replace("-pipeline", "") 

201 legacy_names.add(name) 

202 api_path = f"/project/.agent-platform/{f.name}" 

203 graphs[name] = f"{api_path}:workflow" 

204 print(f"[discover] legacy '{name}' ← {api_path}") 

205 

206 # --- Uusi: YAML-manifest-pipelinet --- 

207 yaml_pipelines = _load_yaml_pipelines(PIPELINE_DIR) 

208 

209 # Kirjoita pipeline registry jotta runner.py voi käyttää sitä 

210 registry_data = { 

211 "manifest_dir": str(PIPELINE_DIR), 

212 "pipelines": [p["name"] for p in yaml_pipelines], 

213 } 

214 REGISTRY_OUTPUT.write_text(json.dumps(registry_data, indent=2)) 

215 

216 # Luo thin-wrapper-tiedostot /tmp/:iin per YAML-pipeline. 

217 # YAML voittaa legacyn jos samanniminen löytyy — uusi totuus. 

218 for p in yaml_pipelines: 

219 name = p["name"] 

220 if name in legacy_names: 

221 print(f"[discover] YAML Pipeline '{name}' OVERRIDES legacy *-pipeline.py") 

222 wrapper_path = Path(f"/tmp/{name}-pipeline.py") 

223 wrapper_path.write_text( 

224 _generate_wrapper(name, p["source"], p["budget"]) 

225 ) 

226 graphs[name] = f"{wrapper_path}:make_workflow" 

227 print(f"[discover] YAML '{name}' → {wrapper_path}") 

228 

229 config: dict = { 

230 "dependencies": ["."], 

231 "graphs": graphs, 

232 } 

233 

234 # PostgreSQL store 

235 if STORE_URI: 

236 config["store"] = { 

237 "postgres": {"uri": STORE_URI}, 

238 } 

239 print("[discover] Store: postgres") 

240 

241 OUTPUT.write_text(json.dumps(config, indent=2)) 

242 

243 total = len(graphs) 

244 yaml_count = len(yaml_pipelines) 

245 legacy_count = len(legacy_names) 

246 if graphs: 

247 print( 

248 f"[discover] Total pipelines: {total} " 

249 f"({legacy_count} legacy, {yaml_count} YAML)" 

250 ) 

251 print(f"[discover] Config → {OUTPUT}") 

252 else: 

253 print("[discover] No pipelines found — LangGraph starts with empty graphs") 

254 

255 

256if __name__ == "__main__": 

257 main()