aethergraph 0.1.0a3__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.
- aethergraph/api/v1/artifacts.py +23 -4
- aethergraph/api/v1/schemas.py +7 -0
- aethergraph/api/v1/session.py +123 -4
- aethergraph/config/config.py +2 -0
- aethergraph/config/search.py +49 -0
- aethergraph/contracts/services/channel.py +18 -1
- aethergraph/contracts/services/execution.py +58 -0
- aethergraph/contracts/services/llm.py +26 -0
- aethergraph/contracts/services/memory.py +10 -4
- aethergraph/contracts/services/planning.py +53 -0
- aethergraph/contracts/storage/event_log.py +8 -0
- aethergraph/contracts/storage/search_backend.py +47 -0
- aethergraph/contracts/storage/vector_index.py +73 -0
- aethergraph/core/graph/action_spec.py +76 -0
- aethergraph/core/graph/graph_fn.py +75 -2
- aethergraph/core/graph/graphify.py +74 -2
- aethergraph/core/runtime/graph_runner.py +2 -1
- aethergraph/core/runtime/node_context.py +66 -3
- aethergraph/core/runtime/node_services.py +8 -0
- aethergraph/core/runtime/run_manager.py +263 -271
- aethergraph/core/runtime/run_types.py +54 -1
- aethergraph/core/runtime/runtime_env.py +35 -14
- aethergraph/core/runtime/runtime_services.py +308 -18
- aethergraph/plugins/agents/default_chat_agent.py +266 -74
- aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
- aethergraph/plugins/channel/adapters/webui.py +69 -21
- aethergraph/plugins/channel/routes/webui_routes.py +8 -48
- aethergraph/runtime/__init__.py +12 -0
- aethergraph/server/app_factory.py +3 -0
- aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
- aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
- aethergraph/server/ui_static/index.html +2 -2
- aethergraph/services/artifacts/facade.py +157 -21
- aethergraph/services/artifacts/types.py +35 -0
- aethergraph/services/artifacts/utils.py +42 -0
- aethergraph/services/channel/channel_bus.py +3 -1
- aethergraph/services/channel/event_hub copy.py +55 -0
- aethergraph/services/channel/event_hub.py +81 -0
- aethergraph/services/channel/factory.py +3 -2
- aethergraph/services/channel/session.py +709 -74
- aethergraph/services/container/default_container.py +69 -7
- aethergraph/services/execution/__init__.py +0 -0
- aethergraph/services/execution/local_python.py +118 -0
- aethergraph/services/indices/__init__.py +0 -0
- aethergraph/services/indices/global_indices.py +21 -0
- aethergraph/services/indices/scoped_indices.py +292 -0
- aethergraph/services/llm/generic_client.py +342 -46
- aethergraph/services/llm/generic_embed_client.py +359 -0
- aethergraph/services/llm/types.py +3 -1
- aethergraph/services/memory/distillers/llm_long_term.py +60 -109
- aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
- aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
- aethergraph/services/memory/distillers/long_term.py +48 -131
- aethergraph/services/memory/distillers/long_term_v1.py +170 -0
- aethergraph/services/memory/facade/chat.py +18 -8
- aethergraph/services/memory/facade/core.py +159 -19
- aethergraph/services/memory/facade/distillation.py +86 -31
- aethergraph/services/memory/facade/retrieval.py +100 -1
- aethergraph/services/memory/factory.py +4 -1
- aethergraph/services/planning/__init__.py +0 -0
- aethergraph/services/planning/action_catalog.py +271 -0
- aethergraph/services/planning/bindings.py +56 -0
- aethergraph/services/planning/dependency_index.py +65 -0
- aethergraph/services/planning/flow_validator.py +263 -0
- aethergraph/services/planning/graph_io_adapter.py +150 -0
- aethergraph/services/planning/input_parser.py +312 -0
- aethergraph/services/planning/missing_inputs.py +28 -0
- aethergraph/services/planning/node_planner.py +613 -0
- aethergraph/services/planning/orchestrator.py +112 -0
- aethergraph/services/planning/plan_executor.py +506 -0
- aethergraph/services/planning/plan_types.py +321 -0
- aethergraph/services/planning/planner.py +617 -0
- aethergraph/services/planning/planner_service.py +369 -0
- aethergraph/services/planning/planning_context_builder.py +43 -0
- aethergraph/services/planning/quick_actions.py +29 -0
- aethergraph/services/planning/routers/__init__.py +0 -0
- aethergraph/services/planning/routers/simple_router.py +26 -0
- aethergraph/services/rag/facade.py +0 -3
- aethergraph/services/scope/scope.py +30 -30
- aethergraph/services/scope/scope_factory.py +15 -7
- aethergraph/services/skills/__init__.py +0 -0
- aethergraph/services/skills/skill_registry.py +465 -0
- aethergraph/services/skills/skills.py +220 -0
- aethergraph/services/skills/utils.py +194 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
- aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
- aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
- aethergraph/storage/memory/event_persist.py +42 -2
- aethergraph/storage/memory/fs_persist.py +32 -2
- aethergraph/storage/search_backend/__init__.py +0 -0
- aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
- aethergraph/storage/search_backend/null_backend.py +34 -0
- aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
- aethergraph/storage/search_backend/utils.py +31 -0
- aethergraph/storage/search_factory.py +75 -0
- aethergraph/storage/vector_index/faiss_index.py +72 -4
- aethergraph/storage/vector_index/sqlite_index.py +521 -52
- aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
- aethergraph/storage/vector_index/utils.py +22 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
- aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
- aethergraph/services/eventhub/event_hub.py +0 -76
- aethergraph/services/llm/generic_client copy.py +0 -691
- aethergraph/services/prompts/file_store.py +0 -41
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a3.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()}
|