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.
Files changed (113) hide show
  1. aethergraph/api/v1/artifacts.py +23 -4
  2. aethergraph/api/v1/schemas.py +7 -0
  3. aethergraph/api/v1/session.py +123 -4
  4. aethergraph/config/config.py +2 -0
  5. aethergraph/config/search.py +49 -0
  6. aethergraph/contracts/services/channel.py +18 -1
  7. aethergraph/contracts/services/execution.py +58 -0
  8. aethergraph/contracts/services/llm.py +26 -0
  9. aethergraph/contracts/services/memory.py +10 -4
  10. aethergraph/contracts/services/planning.py +53 -0
  11. aethergraph/contracts/storage/event_log.py +8 -0
  12. aethergraph/contracts/storage/search_backend.py +47 -0
  13. aethergraph/contracts/storage/vector_index.py +73 -0
  14. aethergraph/core/graph/action_spec.py +76 -0
  15. aethergraph/core/graph/graph_fn.py +75 -2
  16. aethergraph/core/graph/graphify.py +74 -2
  17. aethergraph/core/runtime/graph_runner.py +2 -1
  18. aethergraph/core/runtime/node_context.py +66 -3
  19. aethergraph/core/runtime/node_services.py +8 -0
  20. aethergraph/core/runtime/run_manager.py +263 -271
  21. aethergraph/core/runtime/run_types.py +54 -1
  22. aethergraph/core/runtime/runtime_env.py +35 -14
  23. aethergraph/core/runtime/runtime_services.py +308 -18
  24. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  25. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  26. aethergraph/plugins/channel/adapters/webui.py +69 -21
  27. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  28. aethergraph/runtime/__init__.py +12 -0
  29. aethergraph/server/app_factory.py +3 -0
  30. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  31. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  32. aethergraph/server/ui_static/index.html +2 -2
  33. aethergraph/services/artifacts/facade.py +157 -21
  34. aethergraph/services/artifacts/types.py +35 -0
  35. aethergraph/services/artifacts/utils.py +42 -0
  36. aethergraph/services/channel/channel_bus.py +3 -1
  37. aethergraph/services/channel/event_hub copy.py +55 -0
  38. aethergraph/services/channel/event_hub.py +81 -0
  39. aethergraph/services/channel/factory.py +3 -2
  40. aethergraph/services/channel/session.py +709 -74
  41. aethergraph/services/container/default_container.py +69 -7
  42. aethergraph/services/execution/__init__.py +0 -0
  43. aethergraph/services/execution/local_python.py +118 -0
  44. aethergraph/services/indices/__init__.py +0 -0
  45. aethergraph/services/indices/global_indices.py +21 -0
  46. aethergraph/services/indices/scoped_indices.py +292 -0
  47. aethergraph/services/llm/generic_client.py +342 -46
  48. aethergraph/services/llm/generic_embed_client.py +359 -0
  49. aethergraph/services/llm/types.py +3 -1
  50. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  51. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  52. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  53. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  54. aethergraph/services/memory/distillers/long_term.py +48 -131
  55. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  56. aethergraph/services/memory/facade/chat.py +18 -8
  57. aethergraph/services/memory/facade/core.py +159 -19
  58. aethergraph/services/memory/facade/distillation.py +86 -31
  59. aethergraph/services/memory/facade/retrieval.py +100 -1
  60. aethergraph/services/memory/factory.py +4 -1
  61. aethergraph/services/planning/__init__.py +0 -0
  62. aethergraph/services/planning/action_catalog.py +271 -0
  63. aethergraph/services/planning/bindings.py +56 -0
  64. aethergraph/services/planning/dependency_index.py +65 -0
  65. aethergraph/services/planning/flow_validator.py +263 -0
  66. aethergraph/services/planning/graph_io_adapter.py +150 -0
  67. aethergraph/services/planning/input_parser.py +312 -0
  68. aethergraph/services/planning/missing_inputs.py +28 -0
  69. aethergraph/services/planning/node_planner.py +613 -0
  70. aethergraph/services/planning/orchestrator.py +112 -0
  71. aethergraph/services/planning/plan_executor.py +506 -0
  72. aethergraph/services/planning/plan_types.py +321 -0
  73. aethergraph/services/planning/planner.py +617 -0
  74. aethergraph/services/planning/planner_service.py +369 -0
  75. aethergraph/services/planning/planning_context_builder.py +43 -0
  76. aethergraph/services/planning/quick_actions.py +29 -0
  77. aethergraph/services/planning/routers/__init__.py +0 -0
  78. aethergraph/services/planning/routers/simple_router.py +26 -0
  79. aethergraph/services/rag/facade.py +0 -3
  80. aethergraph/services/scope/scope.py +30 -30
  81. aethergraph/services/scope/scope_factory.py +15 -7
  82. aethergraph/services/skills/__init__.py +0 -0
  83. aethergraph/services/skills/skill_registry.py +465 -0
  84. aethergraph/services/skills/skills.py +220 -0
  85. aethergraph/services/skills/utils.py +194 -0
  86. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  87. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  88. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  89. aethergraph/storage/memory/event_persist.py +42 -2
  90. aethergraph/storage/memory/fs_persist.py +32 -2
  91. aethergraph/storage/search_backend/__init__.py +0 -0
  92. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  93. aethergraph/storage/search_backend/null_backend.py +34 -0
  94. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  95. aethergraph/storage/search_backend/utils.py +31 -0
  96. aethergraph/storage/search_factory.py +75 -0
  97. aethergraph/storage/vector_index/faiss_index.py +72 -4
  98. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  99. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  100. aethergraph/storage/vector_index/utils.py +22 -0
  101. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  102. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
  103. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  104. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  105. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  106. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  107. aethergraph/services/eventhub/event_hub.py +0 -76
  108. aethergraph/services/llm/generic_client copy.py +0 -691
  109. aethergraph/services/prompts/file_store.py +0 -41
  110. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  111. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  112. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  113. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,65 @@
1
+ # aethergraph/services/planning/dependency_index.py
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from aethergraph.core.graph.action_spec import ActionSpec, IOSlot
9
+ from aethergraph.services.planning.action_catalog import ActionCatalog
10
+
11
+
12
+ @dataclass
13
+ class DependencyIndex:
14
+ actions: list[ActionSpec]
15
+
16
+ @classmethod
17
+ def from_catalog(
18
+ cls,
19
+ catalog: ActionCatalog,
20
+ *,
21
+ flow_ids: list[str] | None = None,
22
+ kinds: Iterable[Literal["graph", "graphfn"]] | None = ("graph", "graphfn"),
23
+ include_global: bool = True,
24
+ ) -> DependencyIndex:
25
+ return cls(
26
+ actions=list(
27
+ catalog.list_actions(
28
+ flow_ids=flow_ids,
29
+ kinds=kinds,
30
+ include_global=include_global,
31
+ )
32
+ )
33
+ )
34
+
35
+ def find_producers(
36
+ self,
37
+ needed: IOSlot,
38
+ *,
39
+ flow_ids: list[str] | None = None,
40
+ ) -> list[tuple[ActionSpec, IOSlot]]:
41
+ """
42
+ Find (action, output_slot) pairs whose outputs are compatible with the given input slot.
43
+ If flow_ids is provided, only consider actions within those flows or global ones (the
44
+ actions list is usually pre-filtered by from_catalog).
45
+ """
46
+ matches: list[tuple[ActionSpec, IOSlot]] = []
47
+ for act in self.actions:
48
+ if flow_ids is not None: # noqa: SIM102
49
+ # respect pre-filtering convention: allow actions with flow_id in flow_ids or None
50
+ if act.flow_id not in flow_ids and act.flow_id is not None:
51
+ continue
52
+ for out_slot in act.outputs:
53
+ if self._compatible(needed, out_slot):
54
+ matches.append((act, out_slot))
55
+ return matches
56
+
57
+ @staticmethod
58
+ def _compatible(inp: IOSlot, out: IOSlot) -> bool:
59
+ if inp.type is None or out.type is None:
60
+ return True
61
+ if inp.type == out.type:
62
+ return True
63
+ if inp.type in {"object", "any"} or out.type in {"object", "any"}: # noqa: SIM103
64
+ return True
65
+ return False
@@ -0,0 +1,263 @@
1
+ # aethergraph/services/planning/flow_validator.py
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from aethergraph.core.graph.action_spec import ActionSpec, IOSlot
9
+
10
+ from .action_catalog import ActionCatalog
11
+ from .bindings import parse_binding
12
+ from .dependency_index import DependencyIndex
13
+ from .plan_types import CandidatePlan, PlanStep, ValidationIssue, ValidationResult
14
+
15
+
16
+ @dataclass
17
+ class FlowValidator:
18
+ catalog: ActionCatalog
19
+ dep_index: DependencyIndex | None = None
20
+
21
+ def _action_index(
22
+ self,
23
+ *,
24
+ flow_ids: list[str] | None = None,
25
+ kinds: Iterable[Literal["graph", "graphfn"]] | None = ("graph", "graphfn"),
26
+ ) -> dict[str, ActionSpec]:
27
+ idx: dict[str, ActionSpec] = {}
28
+ for spec in self.catalog.iter_actions(
29
+ flow_ids=flow_ids,
30
+ kinds=kinds,
31
+ include_global=True,
32
+ ):
33
+ idx[spec.ref] = spec
34
+ return idx
35
+
36
+ @staticmethod
37
+ def _is_strict_type_mismatch(
38
+ expected: str | None,
39
+ actual: str | None,
40
+ ) -> bool:
41
+ if not expected or not actual:
42
+ return False
43
+ wildcard = {"any", "object"}
44
+ if expected in wildcard or actual in wildcard:
45
+ return False
46
+ return expected != actual
47
+
48
+ def validate(
49
+ self,
50
+ plan: CandidatePlan,
51
+ *,
52
+ external_inputs: dict[str, IOSlot] | None = None,
53
+ flow_ids: list[str] | None = None,
54
+ ) -> ValidationResult:
55
+ issues: list[ValidationIssue] = []
56
+ external_inputs = external_inputs or {}
57
+ missing_user_bindings: dict[str, list[str]] = {}
58
+
59
+ action_index = self._action_index(flow_ids=flow_ids)
60
+ step_index: dict[str, PlanStep] = {step.id: step for step in plan.steps}
61
+
62
+ # 1) unknown actions
63
+ for step in plan.steps:
64
+ if step.action_ref not in action_index:
65
+ issues.append(
66
+ ValidationIssue(
67
+ kind="unknown_action",
68
+ step_id=step.id,
69
+ field=None,
70
+ message=f"Action '{step.action_ref}' is not recognized.",
71
+ )
72
+ )
73
+
74
+ if any(iss.kind == "unknown_action" for iss in issues):
75
+ # structural error, no missing_user_bindings in this path
76
+ return ValidationResult(
77
+ ok=False,
78
+ issues=issues,
79
+ has_structural_errors=True,
80
+ missing_user_bindings={},
81
+ )
82
+
83
+ # 2) dependency graph
84
+ edges: dict[str, set[str]] = {step.id: set() for step in plan.steps}
85
+ for step in plan.steps:
86
+ for raw in (step.inputs or {}).values():
87
+ binding = parse_binding(raw)
88
+ if binding.kind == "step_output" and binding.source_step_id in step_index:
89
+ edges[step.id].add(binding.source_step_id)
90
+
91
+ # 3) detect cycles
92
+ visiting: set[str] = set()
93
+ visited: set[str] = set()
94
+ has_cycle = False
95
+
96
+ def dfs(node: str) -> None:
97
+ nonlocal has_cycle
98
+ if node in visited or has_cycle:
99
+ return
100
+ if node in visiting:
101
+ has_cycle = True
102
+ return
103
+ visiting.add(node)
104
+ for dep in edges[node]:
105
+ dfs(dep)
106
+ visiting.remove(node)
107
+ visited.add(node)
108
+
109
+ for step_id in edges:
110
+ if step_id not in visited:
111
+ dfs(step_id)
112
+
113
+ if has_cycle:
114
+ issues.append(
115
+ ValidationIssue(
116
+ kind="cycle",
117
+ step_id="",
118
+ field=None,
119
+ message="The plan contains cyclic dependencies among steps.",
120
+ )
121
+ )
122
+ return ValidationResult(
123
+ ok=False,
124
+ issues=issues,
125
+ has_structural_errors=True,
126
+ missing_user_bindings={},
127
+ )
128
+
129
+ # 4) topo order
130
+ in_deg: dict[str, int] = {step_id: 0 for step_id in edges}
131
+ for step_id, deps in edges.items():
132
+ for _ in deps:
133
+ in_deg[step_id] += 1
134
+
135
+ ready = [sid for sid, deg in in_deg.items() if deg == 0]
136
+ topo_order: list[str] = []
137
+ while ready:
138
+ sid = ready.pop()
139
+ topo_order.append(sid)
140
+ for consumer, deps in edges.items():
141
+ if sid in deps:
142
+ in_deg[consumer] -= 1
143
+ if in_deg[consumer] == 0:
144
+ ready.append(consumer)
145
+
146
+ # 5) validate inputs along topo order
147
+ available_outputs: dict[str, IOSlot] = {}
148
+
149
+ for step_id in topo_order:
150
+ step = step_index[step_id]
151
+ spec = action_index[step.action_ref]
152
+ input_by_name = {slot.name: slot for slot in spec.inputs}
153
+
154
+ for name, slot in input_by_name.items():
155
+ raw_value = step.inputs.get(name)
156
+
157
+ if raw_value is None and slot.required:
158
+ details: dict = {}
159
+ if self.dep_index is not None:
160
+ cands = self.dep_index.find_producers(
161
+ slot,
162
+ flow_ids=flow_ids,
163
+ )
164
+ details["candidates"] = [
165
+ {
166
+ "action_ref": a.ref,
167
+ "output": out.name,
168
+ "description": a.description,
169
+ }
170
+ for (a, out) in cands
171
+ ]
172
+ issues.append(
173
+ ValidationIssue(
174
+ kind="missing_input",
175
+ step_id=step_id,
176
+ field=name,
177
+ message=(
178
+ f"Required input '{name}' is not provided "
179
+ f"for action '{spec.name}'."
180
+ ),
181
+ details=details,
182
+ )
183
+ )
184
+ continue
185
+
186
+ if raw_value is None:
187
+ continue
188
+
189
+ binding = parse_binding(raw_value)
190
+
191
+ if binding.kind == "external":
192
+ key = binding.external_key or ""
193
+ ext_slot = external_inputs.get(key)
194
+ if ext_slot is None:
195
+ # treat as “needs user value”, not structural error
196
+ loc = f"{step_id}.{name}"
197
+ missing_user_bindings.setdefault(key, []).append(loc)
198
+ continue
199
+
200
+ # ext_slot can be an IOSlot OR a bare value (when planner passes user_inputs).
201
+ # In the latter case we don't have type info, so we skip strict type checking.
202
+ ext_type = getattr(ext_slot, "type", None)
203
+ if self._is_strict_type_mismatch(slot.type, ext_type):
204
+ issues.append(
205
+ ValidationIssue(
206
+ kind="type_mismatch",
207
+ step_id=step_id,
208
+ field=name,
209
+ message=(
210
+ f"Type mismatch for external input '{key}': "
211
+ f"expected '{slot.type}', got '{ext_slot.type}'."
212
+ ),
213
+ )
214
+ )
215
+
216
+ elif binding.kind == "step_output":
217
+ src_id = binding.source_step_id or ""
218
+ out_name = binding.source_output_name or ""
219
+ key = f"{src_id}.{out_name}"
220
+ out_slot = available_outputs.get(key)
221
+ if out_slot is None:
222
+ issues.append(
223
+ ValidationIssue(
224
+ kind="missing_input",
225
+ step_id=step_id,
226
+ field=name,
227
+ message=(
228
+ f"Input '{name}' refers to '{key}', "
229
+ "which is not produced by any previous step."
230
+ ),
231
+ )
232
+ )
233
+ else:
234
+ if self._is_strict_type_mismatch(slot.type, out_slot.type):
235
+ issues.append(
236
+ ValidationIssue(
237
+ kind="type_mismatch",
238
+ step_id=step_id,
239
+ field=name,
240
+ message=(
241
+ f"Input '{name}' expects type '{slot.type}' "
242
+ f"but is wired from '{key}' with type '{out_slot.type}'."
243
+ ),
244
+ )
245
+ )
246
+
247
+ # literals: accepted, no extra checks yet
248
+
249
+ for out in spec.outputs:
250
+ available_outputs[f"{step_id}.{out.name}"] = out
251
+
252
+ # Decide what counts as “structural” (vs. “needs user values”)
253
+ structural_kinds = {"unknown_action", "cycle", "missing_input", "type_mismatch"}
254
+ has_structural_errors = any(iss.kind in structural_kinds for iss in issues)
255
+
256
+ ok = (not has_structural_errors) and (len(missing_user_bindings) == 0)
257
+
258
+ return ValidationResult(
259
+ ok=ok,
260
+ issues=issues,
261
+ has_structural_errors=has_structural_errors,
262
+ missing_user_bindings=missing_user_bindings,
263
+ )
@@ -0,0 +1,150 @@
1
+ # aethergraph/services/planning/graph_io_adapter.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from aethergraph.core.graph.action_spec import IOSlot, _map_py_type_to_json_type
7
+ from aethergraph.core.graph.task_graph import TaskGraph
8
+
9
+
10
+ def graph_io_to_slots(
11
+ graph: TaskGraph,
12
+ meta: dict[str, Any] | None = None,
13
+ ) -> dict[str, list[IOSlot]]:
14
+ """
15
+ Adapter: TaskGraph.io_signature() + IOSpec + optional registry meta -> IOSlot lists.
16
+
17
+ Priority for types:
18
+ 1) registry meta["io_types"]["inputs"/"outputs"] (set by @graphify)
19
+ 2) ParamSpec.annotation from IOSpec
20
+ 3) None (treated as "any" by planner)
21
+ """
22
+ sig = graph.io_signature(include_values=False)
23
+ io_spec = getattr(graph.spec, "io", None) # IOSpec if present
24
+
25
+ inputs_info = sig.get("inputs", {}) or {}
26
+ outputs_info = sig.get("outputs", {}) or {}
27
+
28
+ # ---- io_types from registry meta (preferred) ----
29
+ io_types = (meta or {}).get("io_types") or {}
30
+ input_type_map: dict[str, str] = io_types.get("inputs", {}) or {}
31
+ output_type_map: dict[str, str] = io_types.get("outputs", {}) or {}
32
+
33
+ # --- INPUTS ---
34
+
35
+ req_raw = inputs_info.get("required") or []
36
+ opt_raw = inputs_info.get("optional") or {}
37
+
38
+ required_names = list(req_raw.keys()) if isinstance(req_raw, dict) else list(req_raw)
39
+ optional_names = list(opt_raw.keys()) if isinstance(opt_raw, dict) else list(opt_raw)
40
+
41
+ input_slots: list[IOSlot] = []
42
+
43
+ def _param_for(name: str):
44
+ if io_spec is None:
45
+ return None
46
+ if hasattr(io_spec, "required") and name in io_spec.required:
47
+ return io_spec.required[name]
48
+ if hasattr(io_spec, "optional") and name in io_spec.optional:
49
+ return io_spec.optional[name]
50
+ return None
51
+
52
+ # required inputs
53
+ for name in required_names:
54
+ ps = _param_for(name)
55
+
56
+ # 1) type from meta.io_types if present
57
+ t_from_meta = input_type_map.get(name)
58
+
59
+ # 2) else type from ParamSpec.annotation
60
+ j_type = None
61
+ default = None
62
+ description = None
63
+ required_flag = True
64
+
65
+ if ps is not None:
66
+ anno = getattr(ps, "annotation", None)
67
+ default = getattr(ps, "default", None)
68
+ required_flag = getattr(ps, "required", True)
69
+ description = getattr(ps, "description", None)
70
+ if t_from_meta is None and anno is not None:
71
+ j_type = _map_py_type_to_json_type(anno)
72
+
73
+ # final type choice
74
+ final_type = t_from_meta or j_type
75
+
76
+ input_slots.append(
77
+ IOSlot(
78
+ name=name,
79
+ type=final_type,
80
+ required=required_flag,
81
+ default=None if required_flag else default,
82
+ description=description,
83
+ )
84
+ )
85
+
86
+ # optional inputs
87
+ for name in optional_names:
88
+ ps = _param_for(name)
89
+
90
+ t_from_meta = input_type_map.get(name)
91
+
92
+ j_type = None
93
+ default = None
94
+ description = None
95
+
96
+ if ps is not None:
97
+ anno = getattr(ps, "annotation", None)
98
+ default = getattr(ps, "default", None)
99
+ description = getattr(ps, "description", None)
100
+ if t_from_meta is None and anno is not None:
101
+ j_type = _map_py_type_to_json_type(anno)
102
+
103
+ final_type = t_from_meta or j_type
104
+
105
+ input_slots.append(
106
+ IOSlot(
107
+ name=name,
108
+ type=final_type,
109
+ required=False,
110
+ default=default,
111
+ description=description,
112
+ )
113
+ )
114
+
115
+ # --- OUTPUTS ---
116
+
117
+ output_keys = outputs_info.get("keys") or []
118
+ output_slots: list[IOSlot] = []
119
+
120
+ def _output_param_for(name: str):
121
+ if io_spec is None or not hasattr(io_spec, "outputs"):
122
+ return None
123
+ return io_spec.outputs.get(name)
124
+
125
+ for name in output_keys:
126
+ ps = _output_param_for(name)
127
+
128
+ t_from_meta = output_type_map.get(name)
129
+
130
+ j_type = None
131
+ description = None
132
+
133
+ if ps is not None:
134
+ anno = getattr(ps, "annotation", None)
135
+ description = getattr(ps, "description", None)
136
+ if t_from_meta is None and anno is not None:
137
+ j_type = _map_py_type_to_json_type(anno)
138
+
139
+ final_type = t_from_meta or j_type
140
+
141
+ output_slots.append(
142
+ IOSlot(
143
+ name=name,
144
+ type=final_type,
145
+ required=True, # outputs are logically “present”
146
+ description=description,
147
+ )
148
+ )
149
+
150
+ return {"inputs": input_slots, "outputs": output_slots}