aethergraph 0.1.0a2__py3-none-any.whl → 0.1.0a4__py3-none-any.whl

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.
Files changed (114) hide show
  1. aethergraph/__main__.py +3 -0
  2. aethergraph/api/v1/artifacts.py +23 -4
  3. aethergraph/api/v1/schemas.py +7 -0
  4. aethergraph/api/v1/session.py +123 -4
  5. aethergraph/config/config.py +2 -0
  6. aethergraph/config/search.py +49 -0
  7. aethergraph/contracts/services/channel.py +18 -1
  8. aethergraph/contracts/services/execution.py +58 -0
  9. aethergraph/contracts/services/llm.py +26 -0
  10. aethergraph/contracts/services/memory.py +10 -4
  11. aethergraph/contracts/services/planning.py +53 -0
  12. aethergraph/contracts/storage/event_log.py +8 -0
  13. aethergraph/contracts/storage/search_backend.py +47 -0
  14. aethergraph/contracts/storage/vector_index.py +73 -0
  15. aethergraph/core/graph/action_spec.py +76 -0
  16. aethergraph/core/graph/graph_fn.py +75 -2
  17. aethergraph/core/graph/graphify.py +74 -2
  18. aethergraph/core/runtime/graph_runner.py +2 -1
  19. aethergraph/core/runtime/node_context.py +66 -3
  20. aethergraph/core/runtime/node_services.py +8 -0
  21. aethergraph/core/runtime/run_manager.py +263 -271
  22. aethergraph/core/runtime/run_types.py +54 -1
  23. aethergraph/core/runtime/runtime_env.py +35 -14
  24. aethergraph/core/runtime/runtime_services.py +308 -18
  25. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  26. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  27. aethergraph/plugins/channel/adapters/webui.py +69 -21
  28. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  29. aethergraph/runtime/__init__.py +12 -0
  30. aethergraph/server/app_factory.py +10 -1
  31. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  32. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  33. aethergraph/server/ui_static/index.html +2 -2
  34. aethergraph/services/artifacts/facade.py +157 -21
  35. aethergraph/services/artifacts/types.py +35 -0
  36. aethergraph/services/artifacts/utils.py +42 -0
  37. aethergraph/services/channel/channel_bus.py +3 -1
  38. aethergraph/services/channel/event_hub copy.py +55 -0
  39. aethergraph/services/channel/event_hub.py +81 -0
  40. aethergraph/services/channel/factory.py +3 -2
  41. aethergraph/services/channel/session.py +709 -74
  42. aethergraph/services/container/default_container.py +69 -7
  43. aethergraph/services/execution/__init__.py +0 -0
  44. aethergraph/services/execution/local_python.py +118 -0
  45. aethergraph/services/indices/__init__.py +0 -0
  46. aethergraph/services/indices/global_indices.py +21 -0
  47. aethergraph/services/indices/scoped_indices.py +292 -0
  48. aethergraph/services/llm/generic_client.py +342 -46
  49. aethergraph/services/llm/generic_embed_client.py +359 -0
  50. aethergraph/services/llm/types.py +3 -1
  51. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  52. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  53. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  54. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  55. aethergraph/services/memory/distillers/long_term.py +48 -131
  56. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  57. aethergraph/services/memory/facade/chat.py +18 -8
  58. aethergraph/services/memory/facade/core.py +159 -19
  59. aethergraph/services/memory/facade/distillation.py +86 -31
  60. aethergraph/services/memory/facade/retrieval.py +100 -1
  61. aethergraph/services/memory/factory.py +4 -1
  62. aethergraph/services/planning/__init__.py +0 -0
  63. aethergraph/services/planning/action_catalog.py +271 -0
  64. aethergraph/services/planning/bindings.py +56 -0
  65. aethergraph/services/planning/dependency_index.py +65 -0
  66. aethergraph/services/planning/flow_validator.py +263 -0
  67. aethergraph/services/planning/graph_io_adapter.py +150 -0
  68. aethergraph/services/planning/input_parser.py +312 -0
  69. aethergraph/services/planning/missing_inputs.py +28 -0
  70. aethergraph/services/planning/node_planner.py +613 -0
  71. aethergraph/services/planning/orchestrator.py +112 -0
  72. aethergraph/services/planning/plan_executor.py +506 -0
  73. aethergraph/services/planning/plan_types.py +321 -0
  74. aethergraph/services/planning/planner.py +617 -0
  75. aethergraph/services/planning/planner_service.py +369 -0
  76. aethergraph/services/planning/planning_context_builder.py +43 -0
  77. aethergraph/services/planning/quick_actions.py +29 -0
  78. aethergraph/services/planning/routers/__init__.py +0 -0
  79. aethergraph/services/planning/routers/simple_router.py +26 -0
  80. aethergraph/services/rag/facade.py +0 -3
  81. aethergraph/services/scope/scope.py +30 -30
  82. aethergraph/services/scope/scope_factory.py +15 -7
  83. aethergraph/services/skills/__init__.py +0 -0
  84. aethergraph/services/skills/skill_registry.py +465 -0
  85. aethergraph/services/skills/skills.py +220 -0
  86. aethergraph/services/skills/utils.py +194 -0
  87. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  88. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  89. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  90. aethergraph/storage/memory/event_persist.py +42 -2
  91. aethergraph/storage/memory/fs_persist.py +32 -2
  92. aethergraph/storage/search_backend/__init__.py +0 -0
  93. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  94. aethergraph/storage/search_backend/null_backend.py +34 -0
  95. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  96. aethergraph/storage/search_backend/utils.py +31 -0
  97. aethergraph/storage/search_factory.py +75 -0
  98. aethergraph/storage/vector_index/faiss_index.py +72 -4
  99. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  100. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  101. aethergraph/storage/vector_index/utils.py +22 -0
  102. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  103. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +108 -64
  104. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  105. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  106. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  107. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  108. aethergraph/services/eventhub/event_hub.py +0 -76
  109. aethergraph/services/llm/generic_client copy.py +0 -691
  110. aethergraph/services/prompts/file_store.py +0 -41
  111. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  112. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  113. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  114. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,617 @@
1
+ # aethergraph/services/planning/planner.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ import inspect
6
+ from typing import Any
7
+
8
+ from aethergraph.contracts.services.llm import LLMClientProtocol
9
+
10
+ from .action_catalog import ActionCatalog
11
+ from .flow_validator import FlowValidator
12
+ from .plan_types import (
13
+ CandidatePlan,
14
+ PlanningContext,
15
+ PlanningEvent,
16
+ PlanningEventCallback,
17
+ ValidationResult,
18
+ )
19
+
20
+
21
+ class PlanDecodingError(Exception):
22
+ """
23
+ Raised when the LLM's response cannot be parsed into a valid JSON plan.
24
+ Carries the raw text so callers can log or surface it.
25
+ """
26
+
27
+ def __init__(self, message: str, raw_text: str | None = None):
28
+ super().__init__(message)
29
+ self.raw_text = raw_text
30
+
31
+
32
+ @dataclass
33
+ class ActionPlanner:
34
+ catalog: ActionCatalog
35
+ validator: FlowValidator
36
+ llm: LLMClientProtocol
37
+
38
+ @staticmethod
39
+ async def _emit(
40
+ on_event: PlanningEventCallback | None,
41
+ event: PlanningEvent,
42
+ ) -> None:
43
+ """
44
+ Small helper to safely emit events if a callback is provided.
45
+ """
46
+ """
47
+ Emit planning events, supporting both sync and async callbacks.
48
+ """
49
+ if on_event is None:
50
+ return
51
+ try:
52
+ result = on_event(event)
53
+ if inspect.isawaitable(result):
54
+ await result
55
+ except Exception:
56
+ import logging
57
+
58
+ logger = logging.getLogger(__name__)
59
+ logger.warning("Error in planning on_event callback", exc_info=True)
60
+
61
+ async def plan_with_loop(
62
+ self,
63
+ ctx: PlanningContext,
64
+ *,
65
+ max_iter: int = 3,
66
+ on_event: PlanningEventCallback | None = None,
67
+ ) -> tuple[CandidatePlan | None, list[ValidationResult]]:
68
+ history: list[ValidationResult] = []
69
+ plan: CandidatePlan | None = None
70
+
71
+ flow_ids = ctx.flow_ids # could be None
72
+ actions_md = self.catalog.pretty_print(
73
+ flow_ids=flow_ids,
74
+ include_global=True,
75
+ )
76
+
77
+ system_prompt = (
78
+ "You are a planning assistant that builds executable workflows as JSON plans. "
79
+ "You must strictly follow the JSON schema and return ONLY JSON, no extra text."
80
+ )
81
+
82
+ await self._emit(
83
+ on_event,
84
+ PlanningEvent(
85
+ phase="start",
86
+ iteration=0,
87
+ message=f"Starting planning for goal: {ctx.goal!r}",
88
+ ),
89
+ )
90
+
91
+ for iter_idx in range(max_iter):
92
+ if plan is None:
93
+ user_prompt = self._build_initial_prompt(ctx, actions_md)
94
+ else:
95
+ last_v = history[-1]
96
+ user_prompt = self._build_repair_prompt(ctx, actions_md, plan, last_v)
97
+
98
+ await self._emit(
99
+ on_event,
100
+ PlanningEvent(
101
+ phase="llm_request",
102
+ iteration=iter_idx,
103
+ message="Sending planning request to LLM.",
104
+ ),
105
+ )
106
+
107
+ try:
108
+ raw_json = await self._call_llm_for_plan(
109
+ system_prompt=system_prompt,
110
+ user_prompt=user_prompt,
111
+ )
112
+ except PlanDecodingError as exc:
113
+ # Treat this as a failed attempt; emit an event and move to next iteration.
114
+ await self._emit(
115
+ on_event,
116
+ PlanningEvent(
117
+ phase="llm_response",
118
+ iteration=iter_idx,
119
+ message=f"LLM response could not be parsed as JSON: {exc}",
120
+ raw_llm_output=getattr(exc, "raw_text", None),
121
+ plan_dict=None,
122
+ ),
123
+ )
124
+ # Do NOT append a ValidationResult here; this is a transport/format error,
125
+ # not a semantic plan error. Just try again if we have remaining iters.
126
+ continue
127
+
128
+ await self._emit(
129
+ on_event,
130
+ PlanningEvent(
131
+ phase="llm_response",
132
+ iteration=iter_idx,
133
+ message="Received plan JSON from LLM.",
134
+ raw_llm_output=raw_json,
135
+ plan_dict=raw_json,
136
+ ),
137
+ )
138
+
139
+ plan = CandidatePlan.from_dict(raw_json)
140
+ plan.resolve_actions(
141
+ self.catalog,
142
+ flow_ids=flow_ids,
143
+ include_global=True,
144
+ )
145
+ external_inputs = ctx.external_slots or (ctx.user_inputs or {})
146
+
147
+ v = self.validator.validate(
148
+ plan,
149
+ external_inputs=external_inputs,
150
+ flow_ids=flow_ids,
151
+ )
152
+ history.append(v)
153
+
154
+ await self._emit(
155
+ on_event,
156
+ PlanningEvent(
157
+ phase="validation",
158
+ iteration=iter_idx,
159
+ message=f"Validation result: {'OK' if v.ok else 'INVALID'}",
160
+ validation=v,
161
+ plan_dict=plan.to_dict(),
162
+ ),
163
+ )
164
+
165
+ if v.ok:
166
+ # Fully valid plan
167
+ await self._emit(
168
+ on_event,
169
+ PlanningEvent(
170
+ phase="success",
171
+ iteration=iter_idx,
172
+ message="Planning succeeded with a valid plan.",
173
+ validation=v,
174
+ plan_dict=plan.to_dict(),
175
+ ),
176
+ )
177
+ break
178
+
179
+ # Accept partial plan if allowed and structurally OK
180
+ if getattr(ctx, "allow_partial_plans", False) and v.is_partial_ok():
181
+ await self._emit(
182
+ on_event,
183
+ PlanningEvent(
184
+ phase="success",
185
+ iteration=iter_idx,
186
+ message=(
187
+ "Planning produced a structurally valid plan "
188
+ "with missing user bindings."
189
+ ),
190
+ validation=v,
191
+ plan_dict=plan.to_dict(),
192
+ ),
193
+ )
194
+ break
195
+
196
+ last = history[-1] if history else None
197
+ accept_partial = (
198
+ last is not None and getattr(ctx, "allow_partial_plans", False) and last.is_partial_ok()
199
+ )
200
+
201
+ if not last or (not last.ok and not accept_partial):
202
+ # Total failure: no fully valid or partial-acceptable plan
203
+ await self._emit(
204
+ on_event,
205
+ PlanningEvent(
206
+ phase="failure",
207
+ iteration=len(history) - 1 if history else -1,
208
+ message="Planning failed to produce a valid or partial-acceptable plan.",
209
+ validation=last,
210
+ plan_dict=plan.to_dict() if plan else None,
211
+ ),
212
+ )
213
+
214
+ # Return the last plan if valid or partial-acceptable, else None
215
+ return plan if last and (last.ok or accept_partial) else None, history
216
+
217
+ def _plan_schema(self) -> dict[str, Any]:
218
+ """
219
+ JSON schema for the plan. We include a plan_version and extras for future evolution.
220
+ """
221
+ return {
222
+ "type": "object",
223
+ "properties": {
224
+ "plan_version": {
225
+ "type": "string",
226
+ "default": "2", # bump to v2
227
+ },
228
+ "steps": {
229
+ "type": "array",
230
+ "items": {
231
+ "type": "object",
232
+ "properties": {
233
+ "id": {"type": "string"},
234
+ "action": {"type": "string"},
235
+ "action_ref": {"type": "string"},
236
+ "inputs": {
237
+ "type": "object",
238
+ "additionalProperties": True,
239
+ },
240
+ "extras": { # extension point
241
+ "type": "object",
242
+ "additionalProperties": True,
243
+ },
244
+ },
245
+ # require action name; action_ref is optional
246
+ "required": ["id", "action", "inputs"],
247
+ "additionalProperties": False,
248
+ },
249
+ },
250
+ "extras": { # extension point at plan level
251
+ "type": "object",
252
+ "additionalProperties": True,
253
+ },
254
+ },
255
+ "required": ["steps"],
256
+ "additionalProperties": False,
257
+ }
258
+
259
+ def _base_planning_header(self, ctx: PlanningContext) -> str:
260
+ """
261
+ Build the core planning header.
262
+
263
+ - Always includes a generic description of the planner role.
264
+ - Optionally includes ctx.instruction as the primary task spec.
265
+ """
266
+ default = (
267
+ "You are a planning assistant that builds executable workflows as JSON plans. "
268
+ "You must strictly follow the JSON schema and return ONLY JSON, no extra text."
269
+ )
270
+
271
+ if ctx.instruction:
272
+ return f"{default}\n\n" f"Primary task instructions:\n{ctx.instruction.strip()}"
273
+
274
+ return default
275
+
276
+ async def _call_llm_for_plan(
277
+ self,
278
+ *,
279
+ system_prompt: str,
280
+ user_prompt: str,
281
+ ) -> dict[str, Any]:
282
+ """
283
+ Call the LLM with our plan schema and return the parsed JSON object.
284
+
285
+ This is robust to models that ignore output_format="json" and wrap
286
+ the JSON in ``` fences or surrounding text.
287
+ """
288
+ messages = [
289
+ {"role": "system", "content": system_prompt},
290
+ {"role": "user", "content": user_prompt},
291
+ ]
292
+
293
+ schema = self._plan_schema()
294
+
295
+ raw, _usage = await self.llm.chat(
296
+ messages,
297
+ output_format="json",
298
+ json_schema=schema,
299
+ schema_name="Plan",
300
+ strict_schema=True,
301
+ validate_json=True,
302
+ )
303
+
304
+ # 1) Already a dict: perfect.
305
+ if isinstance(raw, dict):
306
+ return raw
307
+
308
+ # 2) String: try to recover JSON from it.
309
+ if isinstance(raw, str):
310
+ import json
311
+
312
+ txt = raw.strip()
313
+ if not txt:
314
+ raise PlanDecodingError("Empty LLM response when expecting JSON.", raw_text=raw)
315
+
316
+ # Handle ```json ... ``` or ``` ... ``` fences
317
+ if txt.startswith("```"):
318
+ # strip leading ``` or ```json / ```JSON
319
+ if txt.lower().startswith("```json"):
320
+ txt = txt[len("```json") :].strip()
321
+ else:
322
+ txt = txt[3:].strip()
323
+ # strip trailing ```
324
+ if txt.endswith("```"):
325
+ txt = txt[:-3].strip()
326
+
327
+ # First attempt: parse whole string
328
+ try:
329
+ return json.loads(txt)
330
+ except json.JSONDecodeError:
331
+ # Second attempt: extract the first {...} block
332
+ start = txt.find("{")
333
+ end = txt.rfind("}")
334
+ if start != -1 and end != -1 and end > start:
335
+ candidate = txt[start : end + 1]
336
+ try:
337
+ return json.loads(candidate)
338
+ except json.JSONDecodeError as exc2:
339
+ raise PlanDecodingError(
340
+ f"Cannot parse JSON from LLM response (substring). " f"Error: {exc2}",
341
+ raw_text=raw,
342
+ ) from exc2
343
+
344
+ # No obvious JSON object found
345
+ raise PlanDecodingError(
346
+ "Cannot parse JSON from LLM response (no JSON object found).",
347
+ raw_text=raw,
348
+ ) from None # from None to suppress context
349
+
350
+ # 3) Unsupported type
351
+ raise PlanDecodingError(
352
+ f"LLM returned unsupported structured output type: {type(raw)}; "
353
+ "expected dict or JSON string.",
354
+ raw_text=str(raw),
355
+ )
356
+
357
+ def _build_binding_hints(self, ctx: PlanningContext) -> str:
358
+ """
359
+ Build a small hint section about which fields should be treated as
360
+ user-provided bindings (e.g. dataset_path, grid_spec, hyperparams).
361
+
362
+ Skill metadata is deprecated; we infer candidate binding keys from:
363
+ - ctx.preferred_external_keys (if provided)
364
+ - otherwise the keys in ctx.user_inputs
365
+ """
366
+ preferred_keys: list[str] = []
367
+
368
+ # 1) Respect explicit preferred_external_keys first
369
+ if ctx.preferred_external_keys:
370
+ preferred_keys.extend(ctx.preferred_external_keys)
371
+
372
+ # 2) Fallback: use user_inputs keys if nothing explicit was given
373
+ if not preferred_keys:
374
+ preferred_keys = list((ctx.user_inputs or {}).keys())
375
+
376
+ preferred_keys = sorted(set(preferred_keys))
377
+ if not preferred_keys:
378
+ return ""
379
+
380
+ lines: list[str] = []
381
+ lines.append(
382
+ "Important: When you need any of the following values in the plan, "
383
+ 'you should bind them as external "${user.<key>}" references '
384
+ "instead of inventing new literal values (unless the exact value is "
385
+ "already present in User inputs):"
386
+ )
387
+ for key in preferred_keys:
388
+ lines.append(f"- {key}")
389
+ lines.append("")
390
+ lines.append(
391
+ "For example, if you need one of these keys in a step, "
392
+ 'use "${user.<key>}" instead of inventing new file paths, '
393
+ "numeric grids, or other configuration values."
394
+ )
395
+ lines.append("")
396
+
397
+ return "\n".join(lines)
398
+
399
+ def _build_initial_prompt(self, ctx: PlanningContext, actions_md: str) -> str:
400
+ """
401
+ Initial prompt: describe goal, context, and actions, ask for a first plan.
402
+ """
403
+ user_inputs = ctx.user_inputs or {}
404
+ external_slots = ctx.external_slots or {}
405
+
406
+ # 1) Decide which keys we *want* to advertise as potential ${user.*}
407
+ preferred_keys = list(ctx.preferred_external_keys or [])
408
+ # also include any keys that already have values
409
+ preferred_keys.extend(user_inputs.keys())
410
+ preferred_keys = sorted(set(preferred_keys))
411
+
412
+ # 2) Build a pretty view: show value if we have it, or mark as missing
413
+ external_view: dict[str, Any] = {}
414
+ for key in preferred_keys:
415
+ if key in external_slots:
416
+ # could show a type or descriptor here
417
+ external_view[key] = getattr(external_slots[key], "type", "<slot>")
418
+ elif key in user_inputs:
419
+ external_view[key] = user_inputs[key]
420
+ else:
421
+ external_view[key] = "<NOT PROVIDED YET>"
422
+
423
+ user_inputs_str = repr(user_inputs)
424
+ external_str = repr(external_view)
425
+
426
+ # --- 1) Planning header (instruction-driven) ---
427
+ header = self._base_planning_header(ctx)
428
+
429
+ # --- 2) Memory & artifact snippets (already LLM-friendly strings) ---
430
+ memory_str = ""
431
+ if ctx.memory_snippets:
432
+ memory_str = (
433
+ "Relevant recent context (runs, summaries, etc.):\n"
434
+ + "\n".join(f"- {m}" for m in ctx.memory_snippets)
435
+ + "\n\n"
436
+ )
437
+
438
+ artifact_str = ""
439
+ if ctx.artifact_snippets:
440
+ artifact_str = (
441
+ "Relevant artifacts:\n"
442
+ + "\n".join(f"- {a}" for a in ctx.artifact_snippets)
443
+ + "\n\n"
444
+ )
445
+
446
+ binding_hints = self._build_binding_hints(ctx)
447
+
448
+ # --- 3) Main planning instructions ---
449
+ return (
450
+ f"{header}\n\n"
451
+ f"Goal:\n{ctx.goal}\n\n"
452
+ f"User inputs (available values):\n{user_inputs_str}\n\n"
453
+ f"External bindings (available as `{{user.<key>}}`):\n{external_str}\n\n"
454
+ f"{memory_str}"
455
+ f"{artifact_str}"
456
+ f"{binding_hints}"
457
+ "You have the following actions available:\n"
458
+ f"{actions_md}\n\n"
459
+ "You must create a workflow as a JSON object of the form:\n"
460
+ "{\n"
461
+ ' "steps": [\n'
462
+ " {\n"
463
+ ' "id": "load",\n'
464
+ ' "action": "<one of the action names above>",\n'
465
+ ' "inputs": {\n'
466
+ ' "arg_name": <literal or binding>\n'
467
+ " }\n"
468
+ " },\n"
469
+ " ...\n"
470
+ " ]\n"
471
+ "}\n\n"
472
+ "Bindings can be:\n"
473
+ '- external values, using the syntax "${user.<key>}" where <key> is one of the external bindings. '
474
+ "For configuration-like fields (anything representing user-provided configuration or external resources), "
475
+ "you MUST prefer external bindings over inventing new literal values.\n"
476
+ '- literals, e.g. 0.8 or {"lr": 0.01}, but only when the value is clearly fixed by the goal or already '
477
+ "present in User inputs.\n"
478
+ '- outputs from previous steps, using the syntax "${<step_id>.<output_name>}".\n\n'
479
+ "Make sure that:\n"
480
+ "- step ids are unique,\n"
481
+ "- action is exactly one of the listed action names,\n"
482
+ "- you only reference outputs from earlier steps.\n\n"
483
+ "- Do NOT invent file paths or other external values that are not already provided.\n"
484
+ "- If you need a value that should come from user configuration and it is not known yet, bind it as "
485
+ '"${user.<key>}" instead of inventing a fake literal.\n'
486
+ '- Never hard-code fake paths like "path/to/dataset".\n\n'
487
+ "- If the user refers to “previous run”, “last plan”, or “same as before”, reuse the same logical sequence of actions as past plans for this flow, unless they explicitly request structural changes.\n\n"
488
+ "- If they say “stop after <step>” or “skip <step>”, omit that action from the workflow.\n\n"
489
+ "Return ONLY the JSON object, with no explanation or comments.\n"
490
+ )
491
+
492
+ def _build_initial_prompt_v0(self, ctx: PlanningContext, actions_md: str) -> str:
493
+ """
494
+ Initial prompt: describe goal, context, and actions, ask for a first plan.
495
+ """
496
+ user_inputs = ctx.user_inputs or {}
497
+ external_slots = ctx.external_slots or {}
498
+
499
+ # 1) Decide which keys we *want* to advertise as potential ${user.*}
500
+ preferred_keys = list(ctx.preferred_external_keys or [])
501
+ # also include any keys that already have values
502
+ preferred_keys.extend(user_inputs.keys())
503
+ preferred_keys = sorted(set(preferred_keys))
504
+
505
+ # 2) Build a pretty view: show value if we have it, or mark as missing
506
+ external_view: dict[str, Any] = {}
507
+ for key in preferred_keys:
508
+ if key in external_slots:
509
+ # could show a type or descriptor here
510
+ external_view[key] = getattr(external_slots[key], "type", "<slot>")
511
+ elif key in user_inputs:
512
+ external_view[key] = user_inputs[key]
513
+ else:
514
+ external_view[key] = "<NOT PROVIDED YET>"
515
+
516
+ user_inputs_str = repr(user_inputs)
517
+ external_str = repr(external_view)
518
+
519
+ memory_str = ""
520
+ if ctx.memory_snippets:
521
+ memory_str = (
522
+ "Relevant memory:\n" + "\n".join(f"- {m}" for m in ctx.memory_snippets) + "\n\n"
523
+ )
524
+
525
+ artifact_str = ""
526
+ if ctx.artifact_snippets:
527
+ artifact_str = (
528
+ "Relevant artifacts:\n"
529
+ + "\n".join(f"- {a}" for a in ctx.artifact_snippets)
530
+ + "\n\n"
531
+ )
532
+
533
+ return (
534
+ f"Goal:\n{ctx.goal}\n\n"
535
+ f"User inputs (available values):\n{user_inputs_str}\n\n"
536
+ f"External bindings (available as `{{user.<key>}}`):\n{external_str}\n\n"
537
+ f"{memory_str}"
538
+ f"{artifact_str}"
539
+ "You have the following actions available:\n"
540
+ f"{actions_md}\n\n"
541
+ "You must create a workflow as a JSON object of the form:\n"
542
+ "{\n"
543
+ ' "steps": [\n'
544
+ " {\n"
545
+ ' "id": "load",\n'
546
+ ' "action": "<one of the action names above>",\n'
547
+ ' "inputs": {\n'
548
+ ' "arg_name": <literal or binding>\n'
549
+ " }\n"
550
+ " },\n"
551
+ " ...\n"
552
+ " ]\n"
553
+ "}\n\n"
554
+ "Bindings can be:\n"
555
+ '- external values, using the syntax "${user.<key>}" where <key> is one of the external bindings. '
556
+ "For configuration-like fields (such as dataset paths, grid specs, hyperparameters, or ratios), "
557
+ "you MUST prefer external bindings over inventing new literal values.\n"
558
+ '- literals, e.g. 0.8 or {"lr": 0.01}, but only when the value is clearly fixed by the goal or already '
559
+ "present in User inputs.\n"
560
+ '- outputs from previous steps, using the syntax "${<step_id>.<output_name>}".\n\n'
561
+ "Make sure that:\n"
562
+ "- step ids are unique,\n"
563
+ "- action_ref exactly matches one of the listed action refs,\n"
564
+ "- you only reference outputs from earlier steps.\n\n"
565
+ "- Do NOT invent file paths or other external values that are not already provided.\n"
566
+ '- If you need something like a dataset path and it is not known yet, bind it as "${user.dataset_path}".\n'
567
+ '- Never hard-code fake paths like "path/to/dataset".\n\n'
568
+ "Return ONLY the JSON object, with no explanation or comments."
569
+ )
570
+
571
+ def _build_repair_prompt(
572
+ self,
573
+ ctx: PlanningContext,
574
+ actions_md: str,
575
+ plan: CandidatePlan,
576
+ validation: ValidationResult,
577
+ ) -> str:
578
+ """
579
+ Repair prompt: show the current plan + validation summary and ask the LLM
580
+ to return an improved JSON plan.
581
+ """
582
+ user_inputs_str = repr(ctx.user_inputs or {})
583
+ external_dict = ctx.external_slots or (ctx.user_inputs or {})
584
+ external_str = repr(external_dict)
585
+
586
+ header = self._base_planning_header(ctx)
587
+
588
+ # Optional: include some compact context again if we want
589
+ memory_str = ""
590
+ if ctx.memory_snippets:
591
+ memory_str = (
592
+ "Relevant recent context (runs, summaries, etc.):\n"
593
+ + "\n".join(f"- {m}" for m in ctx.memory_snippets)
594
+ + "\n\n"
595
+ )
596
+
597
+ return (
598
+ f"{header}\n\n"
599
+ f"Goal:\n{ctx.goal}\n\n"
600
+ f"User inputs (available values):\n{user_inputs_str}\n\n"
601
+ f"External bindings (available as `{{user.<key>}}`):\n{external_str}\n\n"
602
+ f"{memory_str}"
603
+ "You have the following actions available:\n"
604
+ f"{actions_md}\n\n"
605
+ "Here is the current candidate plan JSON:\n"
606
+ f"{plan.to_dict()}\n\n"
607
+ "Validation result for this plan:\n"
608
+ f"{validation.summary()}\n\n"
609
+ "Some issues may also include candidate actions that can provide missing inputs.\n\n"
610
+ "Please return a corrected plan as a JSON object of the SAME SHAPE:\n"
611
+ '{ "steps": [ { "id": "...", "action_ref": "...", "inputs": { ... } } ] }\n\n'
612
+ "You may:\n"
613
+ "- add, remove, or reorder steps,\n"
614
+ "- change action_ref values to valid actions,\n"
615
+ "- fix input bindings and literals.\n\n"
616
+ "Return ONLY the JSON object, with no explanation or comments."
617
+ )