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,112 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from aethergraph.contracts.services.planning import (
6
+ IntentRouter,
7
+ SessionState,
8
+ )
9
+ from aethergraph.services.planning.plan_executor import (
10
+ ExecutionResult,
11
+ PlanExecutor,
12
+ )
13
+ from aethergraph.services.planning.plan_types import CandidatePlan
14
+ from aethergraph.services.planning.planner import ActionPlanner
15
+
16
+ from .planning_context_builder import PlanningContextBuilderProtocol
17
+ from .quick_actions import QuickActionRegistry
18
+
19
+
20
+ @dataclass
21
+ class AgentTurnResult:
22
+ mode: str
23
+ message_to_user: str | None = None
24
+ plan: CandidatePlan | None = None
25
+ execution: ExecutionResult | None = None
26
+
27
+
28
+ @dataclass
29
+ class AgentOrchestrator:
30
+ router: IntentRouter
31
+ context_builder: PlanningContextBuilderProtocol
32
+ planner: ActionPlanner
33
+ executor: PlanExecutor
34
+ quick_actions: QuickActionRegistry
35
+
36
+ async def handle_turn(
37
+ self,
38
+ *,
39
+ user_message: str,
40
+ session_state: SessionState,
41
+ ) -> AgentTurnResult:
42
+ # 1) Route intent
43
+ routed = await self.router.route(
44
+ user_message=user_message,
45
+ session_state=session_state,
46
+ )
47
+
48
+ # 2) Mode dispatch
49
+ if routed.mode == "chat_only":
50
+ return AgentTurnResult(
51
+ mode="chat_only",
52
+ message_to_user="(chat-only mode: not yet implemented)",
53
+ )
54
+
55
+ if routed.mode == "quick_action":
56
+ handler = self.quick_actions.get_handler(routed.quick_action_id or "")
57
+ if handler is None:
58
+ return AgentTurnResult(
59
+ mode="quick_action",
60
+ message_to_user=f"Unknown quick action: {routed.quick_action_id}",
61
+ )
62
+ result = await handler(context={"user_message": user_message})
63
+ return AgentTurnResult(
64
+ mode="quick_action",
65
+ message_to_user=f"Quick action {routed.quick_action_id} done: {result!r}",
66
+ )
67
+
68
+ if routed.mode == "plan_and_execute":
69
+ # 3) Build planning context
70
+ planning_context = await self.context_builder.build(
71
+ user_message=user_message,
72
+ routed=routed,
73
+ session_state=session_state,
74
+ )
75
+
76
+ # 4) Plan
77
+ plan, history = await self.planner.plan_with_loop(planning_context)
78
+ if plan is None:
79
+ return AgentTurnResult(
80
+ mode="plan_and_execute",
81
+ message_to_user=(
82
+ "I tried to build a plan but couldn't find a valid workflow. "
83
+ "You may need to provide more details."
84
+ ),
85
+ )
86
+
87
+ # 5) Execute
88
+ execution_result = await self.executor.execute(
89
+ plan,
90
+ user_inputs=planning_context.user_inputs,
91
+ )
92
+
93
+ if not execution_result.ok:
94
+ return AgentTurnResult(
95
+ mode="plan_and_execute",
96
+ message_to_user="The plan failed during execution.",
97
+ plan=plan,
98
+ execution=execution_result,
99
+ )
100
+
101
+ return AgentTurnResult(
102
+ mode="plan_and_execute",
103
+ message_to_user=f"Plan executed successfully. Outputs: {execution_result.outputs!r}",
104
+ plan=plan,
105
+ execution=execution_result,
106
+ )
107
+
108
+ # future unkonwn modes
109
+ return AgentTurnResult(
110
+ mode=routed.mode,
111
+ message_to_user=f"Unsupported mode: {routed.mode}",
112
+ )
@@ -0,0 +1,506 @@
1
+ # aethergraph/services/planning/plan_executor.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass, field
7
+ import inspect
8
+ from typing import TYPE_CHECKING, Any, Literal
9
+ from uuid import uuid4
10
+
11
+ from aethergraph.core.runtime.run_types import RunImportance, RunOrigin, RunVisibility
12
+ from aethergraph.services.planning.action_catalog import ActionCatalog
13
+ from aethergraph.services.planning.bindings import parse_binding
14
+
15
+ if TYPE_CHECKING:
16
+ from aethergraph.api.v1.deps import RequestIdentity
17
+ from aethergraph.core.runtime.run_manager import RunManager
18
+ from aethergraph.services.planning.plan_types import CandidatePlan, ExecutionEventCallback
19
+
20
+ ExecutionPhase = Literal[
21
+ "start",
22
+ "step_start",
23
+ "step_success",
24
+ "step_failure",
25
+ "success",
26
+ "failure",
27
+ ]
28
+
29
+
30
+ @dataclass
31
+ class ExecutionEvent:
32
+ """
33
+ Represents an event emitted during the execution of a plan.
34
+ This class is used to encapsulate information about the current state of
35
+ execution, which can be useful for logging purposes or updating the UI
36
+ with progress information.
37
+
38
+ Attributes:
39
+ phase (ExecutionPhase): The current phase of execution.
40
+ step_id (str | None): The identifier of the current step, if applicable.
41
+ message (str | None): An optional message providing additional context
42
+ about the event.
43
+ step_outputs (dict[str, Any] | None): Outputs produced by the step,
44
+ applicable for success phases.
45
+ error (Exception | None): The exception raised during execution,
46
+ applicable for failure phases.
47
+ """
48
+
49
+ phase: ExecutionPhase
50
+ step_id: str | None = None
51
+ message: str | None = None
52
+
53
+ # For success phases
54
+ step_outputs: dict[str, Any] | None = None
55
+
56
+ # For failures
57
+ error: Exception | None = None
58
+
59
+
60
+ @dataclass
61
+ class ExecutionResult:
62
+ """
63
+ Represents the result of an execution process.
64
+
65
+ Attributes:
66
+ ok (bool): Indicates whether the execution was successful.
67
+ outputs (dict[str, Any]): A dictionary containing the outputs of the execution.
68
+ errors (list[ExecutionEvent]): A list of errors that occurred during execution. Defaults to an empty list.
69
+ """
70
+
71
+ ok: bool
72
+ outputs: dict[str, Any]
73
+ errors: list[ExecutionEvent] = field(default_factory=list)
74
+
75
+
76
+ @dataclass
77
+ class BackgroundExecutionHandle:
78
+ """
79
+ BackgroundExecutionHandle is a data class that represents a handle for managing
80
+ the execution of a background task.
81
+
82
+ Attributes:
83
+ id (str): A unique identifier for the execution handle.
84
+ plan_id (str | None): The identifier of the associated plan, if any.
85
+ task (asyncio.Task[ExecutionResult]): The asyncio task representing the background execution.
86
+
87
+ Methods:
88
+ done() -> bool:
89
+ Checks if the background task has completed.
90
+ cancelled() -> bool:
91
+ Checks if the background task has been cancelled.
92
+ cancel() -> None:
93
+ Cancels the background task.
94
+ wait() -> ExecutionResult:
95
+ Awaits the completion of the background task and returns its result.
96
+ """
97
+
98
+ id: str
99
+ plan_id: str | None
100
+ task: asyncio.Task[ExecutionResult]
101
+
102
+ def done(self) -> bool:
103
+ return self.task.done()
104
+
105
+ def cancelled(self) -> bool:
106
+ return self.task.cancelled()
107
+
108
+ def cancel(self) -> None:
109
+ self.task.cancel()
110
+
111
+ async def wait(self) -> ExecutionResult:
112
+ return await self.task
113
+
114
+
115
+ OnCompleteCallback = Callable[[ExecutionResult], Any | Awaitable[Any]]
116
+
117
+
118
+ @dataclass
119
+ class PlanExecutor:
120
+ """
121
+ Execute a validated CandidatePlan by invoking graphfns/graphs.
122
+
123
+ By default this uses run_async for each step (no RunStore / concurrency limits).
124
+ If a RunManager is provided, steps are executed via:
125
+
126
+ submit_run(...) + wait_run(..., return_outputs=True)
127
+
128
+ which:
129
+ - honors max_concurrent_runs
130
+ - creates RunRecords visible in the UI
131
+ - still exposes real Python outputs to the planner.
132
+
133
+ Assumptions:
134
+ - The plan has already been validated by FlowValidator.
135
+ - action_ref strings correspond to registry entries in the ActionCatalog,
136
+ using canonical refs like 'graph:foo@0.1.0' or 'graphfn:bar@0.1.0'.
137
+ - Bindings use the syntax:
138
+ * "${step_id.output_name}" for step outputs
139
+ * "${user.key}" for external/user inputs
140
+ """
141
+
142
+ catalog: ActionCatalog
143
+ # Optional: if provided, we use it instead of run_async for steps
144
+ run_manager: RunManager | None = None
145
+
146
+ # convenient access
147
+ @property
148
+ def registry(self):
149
+ return self.catalog.registry
150
+
151
+ async def execute(
152
+ self,
153
+ plan: CandidatePlan,
154
+ *,
155
+ user_inputs: dict[str, Any] | None = None,
156
+ on_event: ExecutionEventCallback | None = None,
157
+ # Optional execution context if using RunManager
158
+ identity: RequestIdentity | None = None,
159
+ visibility: RunVisibility | None = RunVisibility.normal,
160
+ importance: RunImportance | None = RunImportance.normal,
161
+ session_id: str | None = None,
162
+ agent_id: str | None = None,
163
+ app_id: str | None = None,
164
+ tags: list[str] | None = None,
165
+ origin: RunOrigin | None = None,
166
+ ) -> ExecutionResult:
167
+ """
168
+ Execute all steps in the plan in order.
169
+
170
+ Args:
171
+ plan: The candidate plan to execute (assumed validated).
172
+ user_inputs: Values that can be referenced as "${user.<key>}".
173
+ on_event: Optional callback to receive ExecutionEvent updates.
174
+ identity/session_id/agent_id/app_id/tags/origin:
175
+ Optional context passed through to RunManager when present.
176
+
177
+ Returns:
178
+ ExecutionResult:
179
+ - ok=True and final step outputs on success.
180
+ - ok=False and errors populated on first failure.
181
+ """
182
+ user_inputs = user_inputs or {}
183
+ step_results: dict[str, dict[str, Any]] = {} # step_id -> outputs dict
184
+ errors: list[ExecutionEvent] = []
185
+ base_tags = list(tags or [])
186
+
187
+ await self._emit(
188
+ on_event,
189
+ ExecutionEvent(
190
+ phase="start",
191
+ message="Starting plan execution.",
192
+ ),
193
+ )
194
+
195
+ # Try to extract a plan id (best-effort, no hard dependency on field names)
196
+ plan_id = getattr(plan, "id", None) or getattr(plan, "plan_id", None)
197
+
198
+ # For now we assume steps are already in topological order (validator checked cycles).
199
+ for step in plan.steps:
200
+ await self._emit(
201
+ on_event,
202
+ ExecutionEvent(
203
+ phase="step_start",
204
+ step_id=step.id,
205
+ message=f"Executing step '{step.id}' with action_ref='{step.action_ref}'.",
206
+ ),
207
+ )
208
+
209
+ try:
210
+ # Look up the underlying action object from the registry.
211
+ action_obj = self.registry.get(step.action_ref)
212
+
213
+ # Resolve inputs (bindings + literals)
214
+ bound_inputs = self._resolve_inputs(
215
+ raw_inputs=step.inputs or {},
216
+ step_results=step_results,
217
+ user_inputs=user_inputs,
218
+ )
219
+
220
+ # Decide execution path:
221
+ # - If run_manager is None: use run_async (legacy/simple mode)
222
+ # - Else: use RunManager to honor concurrency + RunStore
223
+ if self.run_manager is None:
224
+ from aethergraph.runner import run_async # avoid top-level import cycle
225
+
226
+ outputs = await run_async(action_obj, inputs=bound_inputs)
227
+ else:
228
+ graph_id = self._graph_id_from_action_ref(step.action_ref)
229
+
230
+ # Compose tags for this step-run
231
+ step_tags = list(base_tags)
232
+ if plan_id is not None:
233
+ step_tags.append(f"plan:{plan_id}")
234
+ step_tags.append(f"plan_step:{step.id}")
235
+
236
+ # Spawn the run
237
+ run_id = (
238
+ f"plan-{plan_id or 'na'}-{session_id or 'na'}-{step.id}-{uuid4().hex[:8]}"
239
+ )
240
+ run_record = await self.run_manager.submit_run(
241
+ graph_id=graph_id,
242
+ inputs=bound_inputs,
243
+ run_id=run_id,
244
+ session_id=session_id,
245
+ identity=identity,
246
+ origin=origin,
247
+ visibility=visibility,
248
+ importance=importance,
249
+ agent_id=agent_id,
250
+ app_id=app_id,
251
+ tags=step_tags,
252
+ )
253
+ # Wait for completion and grab real Python outputs
254
+ finished_rec, outputs = await self.run_manager.wait_run(
255
+ run_record.run_id,
256
+ return_outputs=True,
257
+ )
258
+
259
+ # Interpret non-succeeded as failure
260
+ status_str = getattr(finished_rec.status, "value", str(finished_rec.status))
261
+ if status_str != "succeeded":
262
+ # Mirror the run_async behaviour: raise so we hit the except below
263
+ raise RuntimeError(
264
+ f"Run for step '{step.id}' failed with status={status_str}, "
265
+ f"error={finished_rec.error!r}"
266
+ )
267
+
268
+ # outputs may still be None if something went very wrong; guard it
269
+ if outputs is None:
270
+ outputs = {}
271
+
272
+ # Store step outputs for later bindings
273
+ step_results[step.id] = outputs
274
+
275
+ await self._emit(
276
+ on_event,
277
+ ExecutionEvent(
278
+ phase="step_success",
279
+ step_id=step.id,
280
+ message=f"Step '{step.id}' completed.",
281
+ step_outputs=outputs,
282
+ ),
283
+ )
284
+
285
+ except Exception as exc: # noqa: BLE001
286
+ failure_event = ExecutionEvent(
287
+ phase="step_failure",
288
+ step_id=step.id,
289
+ message=f"Step '{step.id}' failed: {exc!r}",
290
+ error=exc,
291
+ )
292
+ errors.append(failure_event)
293
+ await self._emit(on_event, failure_event)
294
+
295
+ # For v1: fail fast and mark the whole plan as failed.
296
+ final_failure = ExecutionEvent(
297
+ phase="failure",
298
+ step_id=step.id,
299
+ message="Plan execution aborted due to step failure.",
300
+ error=exc,
301
+ )
302
+ errors.append(final_failure)
303
+ await self._emit(on_event, final_failure)
304
+
305
+ return ExecutionResult(
306
+ ok=False,
307
+ outputs={},
308
+ errors=errors,
309
+ )
310
+
311
+ # If we reach here, all steps succeeded.
312
+ final_step = plan.steps[-1]
313
+ final_outputs = step_results.get(final_step.id, {})
314
+
315
+ success_event = ExecutionEvent(
316
+ phase="success",
317
+ step_id=final_step.id,
318
+ message="Plan execution finished successfully.",
319
+ step_outputs=final_outputs,
320
+ )
321
+ await self._emit(on_event, success_event)
322
+
323
+ return ExecutionResult(
324
+ ok=True,
325
+ outputs=final_outputs,
326
+ errors=errors,
327
+ )
328
+
329
+ def execute_background(
330
+ self,
331
+ plan: CandidatePlan,
332
+ *,
333
+ user_inputs: dict[str, Any] | None = None,
334
+ on_event: ExecutionEventCallback | None = None,
335
+ identity: RequestIdentity | None = None,
336
+ visibility: RunVisibility | None = RunVisibility.normal,
337
+ importance: RunImportance | None = RunImportance.normal,
338
+ session_id: str | None = None,
339
+ agent_id: str | None = None,
340
+ app_id: str | None = None,
341
+ tags: list[str] | None = None,
342
+ origin: RunOrigin | None = None,
343
+ # callback when done
344
+ on_complete: Callable[[ExecutionResult], Any] | None = None,
345
+ # explicit id for tracing
346
+ exec_id: str | None = None,
347
+ ) -> BackgroundExecutionHandle:
348
+ """
349
+ Fire-and-forget convenience wrapper around execute().
350
+
351
+ - Schedules execute(...) on the current event loop using
352
+ asyncio.create_task()
353
+ - Returns immediately with a BackgroundExecutionHandle
354
+ - Still uses on_event for streaming progress
355
+ - Optionally triggers on_complete(result) when finished
356
+
357
+ NOTE: This does not change execution semantics; it's just
358
+ a lightweight scheduler helper. Orchestrator can use this
359
+ to let the user continue chatting while a plan runs.
360
+ """
361
+ loop = asyncio.get_event_loop()
362
+
363
+ plan_id = getattr(plan, "id", None) or getattr(plan, "plan_id", None)
364
+ exec_id = exec_id or f"bgexec-{plan_id or 'na'}-{uuid4().hex[:8]}"
365
+
366
+ async def _runner() -> ExecutionResult:
367
+ return await self.execute(
368
+ plan,
369
+ user_inputs=user_inputs,
370
+ on_event=on_event,
371
+ identity=identity,
372
+ visibility=visibility,
373
+ importance=importance,
374
+ session_id=session_id,
375
+ agent_id=agent_id,
376
+ app_id=app_id,
377
+ tags=tags,
378
+ origin=origin,
379
+ )
380
+
381
+ task: asyncio.Task[ExecutionResult] = loop.create_task(_runner(), name=exec_id)
382
+
383
+ if on_complete is not None:
384
+
385
+ def _done_callback(t: asyncio.Task[ExecutionResult]) -> None:
386
+ try:
387
+ result = t.result()
388
+ except Exception as exc:
389
+ # very defensive: crash in execute()
390
+ # wrap the crash into ExecutionResult so caller get a structured error instead of an unhandled exception
391
+ failure_evt = ExecutionEvent(
392
+ phase="failure",
393
+ step_id=None,
394
+ message=f"Background execution '{exec_id}' failed: {t.exception()!r}",
395
+ error=exc,
396
+ )
397
+ result = ExecutionResult(
398
+ ok=False,
399
+ outputs={},
400
+ errors=[failure_evt],
401
+ )
402
+ maybe_awaitable = on_complete(result)
403
+ if inspect.isawaitable(maybe_awaitable):
404
+ # Fire and forget the completion callback as well
405
+ loop.create_task(maybe_awaitable)
406
+
407
+ task.add_done_callback(_done_callback)
408
+
409
+ return BackgroundExecutionHandle(
410
+ id=exec_id,
411
+ plan_id=plan_id,
412
+ task=task,
413
+ )
414
+
415
+ # ------------------------------------------------------------------
416
+ # Internal helpers
417
+ # ------------------------------------------------------------------
418
+
419
+ @staticmethod
420
+ async def _emit(
421
+ on_event: ExecutionEventCallback | None,
422
+ event: ExecutionEvent,
423
+ ) -> None:
424
+ if on_event is None:
425
+ return
426
+ try:
427
+ result = on_event(event)
428
+ if inspect.isawaitable(result):
429
+ await result
430
+ except Exception:
431
+ # Don't let logging/UI errors break execution
432
+ import logging
433
+
434
+ logger = logging.getLogger(__name__)
435
+ logger.warning("Error in execution on_event callback", exc_info=True)
436
+
437
+ @staticmethod
438
+ def _graph_id_from_action_ref(action_ref: str) -> str:
439
+ """
440
+ Extract the graph_id name from a canonical action_ref, e.g.:
441
+
442
+ "graph:foo@0.1.0" -> "foo"
443
+ "graphfn:bar@0.1.0" -> "bar"
444
+
445
+ If the ref doesn't match this pattern, we fall back to the tail
446
+ after ":" (or the whole string).
447
+ """
448
+ ref = action_ref
449
+ if ":" in ref:
450
+ _, ref = ref.split(":", 1)
451
+ if "@" in ref:
452
+ ref, _ = ref.split("@", 1)
453
+ return ref
454
+
455
+ def _resolve_inputs(
456
+ self,
457
+ *,
458
+ raw_inputs: dict[str, Any],
459
+ step_results: dict[str, dict[str, Any]],
460
+ user_inputs: dict[str, Any],
461
+ ) -> dict[str, Any]:
462
+ """
463
+ Resolve all input values for a step:
464
+ - literals: kept as-is
465
+ - "${step_id.output_name}" -> previous step's outputs
466
+ - "${user.key}" -> user_inputs["key"]
467
+
468
+ We do this recursively, so bindings can appear inside nested dicts/lists.
469
+ """
470
+
471
+ def resolve_value(val: Any) -> Any:
472
+ # strings: may be bindings
473
+ if isinstance(val, str):
474
+ binding = parse_binding(val)
475
+
476
+ if binding.kind == "step_output":
477
+ src_id = binding.source_step_id or ""
478
+ out_name = binding.source_output_name or ""
479
+ try:
480
+ return step_results[src_id][out_name]
481
+ except KeyError as exc: # should be prevented by validator
482
+ raise KeyError(
483
+ f"Unknown step output reference: {src_id}.{out_name}"
484
+ ) from exc
485
+
486
+ if binding.kind == "external":
487
+ key = binding.external_key or ""
488
+ if key not in user_inputs:
489
+ raise KeyError(f"Missing user input for external binding: {key}")
490
+ return user_inputs[key]
491
+
492
+ # literal or unsupported binding kinds: keep as-is
493
+ return val
494
+
495
+ # dict: resolve recursively
496
+ if isinstance(val, dict):
497
+ return {k: resolve_value(v) for k, v in val.items()}
498
+
499
+ # list/tuple: resolve recursively
500
+ if isinstance(val, (list, tuple)): # noqa: UP038
501
+ return [resolve_value(v) for v in val]
502
+
503
+ # anything else: leave unchanged
504
+ return val
505
+
506
+ return {name: resolve_value(v) for name, v in raw_inputs.items()}