krons 0.1.1__py3-none-any.whl → 0.2.0__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 (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +126 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +305 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/{specs → work}/phrase.py +130 -13
  112. krons/{enforcement → work}/policy.py +3 -3
  113. krons/work/report.py +268 -0
  114. krons/work/rules/__init__.py +47 -0
  115. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  116. krons/{enforcement → work/rules}/common/choice.py +9 -3
  117. krons/{enforcement → work/rules}/common/number.py +3 -1
  118. krons/{enforcement → work/rules}/common/string.py +9 -3
  119. krons/{enforcement → work/rules}/rule.py +1 -1
  120. krons/{enforcement → work/rules}/validator.py +20 -5
  121. krons/{enforcement → work}/service.py +16 -7
  122. krons/work/worker.py +266 -0
  123. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
  124. krons-0.2.0.dist-info/RECORD +154 -0
  125. krons/enforcement/__init__.py +0 -57
  126. krons/operations/registry.py +0 -92
  127. krons/services/__init__.py +0 -81
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  142. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
krons/work/form.py ADDED
@@ -0,0 +1,305 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Form - Data binding and scheduling for work units.
5
+
6
+ A Form represents an instantiated work unit with:
7
+ - Data binding (input values)
8
+ - Execution state tracking (filled, workable)
9
+ - Optional Phrase reference for typed I/O
10
+
11
+ Forms are the stateful layer between Phrase (definition) and Operation (execution).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from pydantic import Field
20
+
21
+ from krons.core import Element
22
+
23
+ if TYPE_CHECKING:
24
+ from .phrase import Phrase
25
+
26
+ __all__ = ("Form", "ParsedAssignment", "parse_assignment", "parse_full_assignment")
27
+
28
+
29
+ @dataclass
30
+ class ParsedAssignment:
31
+ """Parsed form assignment with all components.
32
+
33
+ Attributes:
34
+ branch: Branch/worker name (e.g., "classifier1")
35
+ inputs: Input field names
36
+ outputs: Output field names
37
+ resource: Resource hint (e.g., "api:fast")
38
+ raw: Original assignment string
39
+ """
40
+
41
+ branch: str | None
42
+ inputs: list[str]
43
+ outputs: list[str]
44
+ resource: str | None
45
+ raw: str
46
+
47
+
48
+ def parse_assignment(assignment: str) -> tuple[list[str], list[str]]:
49
+ """Parse 'inputs -> outputs' assignment DSL (simple form).
50
+
51
+ Args:
52
+ assignment: DSL string like "a, b -> c, d"
53
+
54
+ Returns:
55
+ Tuple of (input_fields, output_fields)
56
+
57
+ Raises:
58
+ ValueError: If assignment format is invalid
59
+ """
60
+ parsed = parse_full_assignment(assignment)
61
+ return parsed.inputs, parsed.outputs
62
+
63
+
64
+ def parse_full_assignment(assignment: str) -> ParsedAssignment:
65
+ """Parse full assignment DSL with branch and resource hints.
66
+
67
+ Format: "branch: inputs -> outputs | resource"
68
+
69
+ Examples:
70
+ "a, b -> c" # Simple
71
+ "classifier: job -> role | api:fast" # Full
72
+ "writer: context -> summary" # Branch, no resource
73
+
74
+ Args:
75
+ assignment: DSL string
76
+
77
+ Returns:
78
+ ParsedAssignment with all components
79
+
80
+ Raises:
81
+ ValueError: If format is invalid
82
+ """
83
+ raw = assignment.strip()
84
+ branch = None
85
+ resource = None
86
+
87
+ # Extract resource hint (after |)
88
+ if "|" in raw:
89
+ main_part, resource_part = raw.rsplit("|", 1)
90
+ resource = resource_part.strip()
91
+ raw = main_part.strip()
92
+
93
+ # Extract branch name (before :)
94
+ if ":" in raw:
95
+ # Check it's not just inside the field list
96
+ colon_idx = raw.find(":")
97
+ arrow_idx = raw.find("->")
98
+ if arrow_idx == -1 or colon_idx < arrow_idx:
99
+ branch_part, raw = raw.split(":", 1)
100
+ branch = branch_part.strip()
101
+ raw = raw.strip()
102
+
103
+ # Parse inputs -> outputs
104
+ if "->" not in raw:
105
+ raise ValueError(f"Invalid assignment syntax (missing '->'): {assignment}")
106
+
107
+ parts = raw.split("->")
108
+ if len(parts) != 2:
109
+ raise ValueError(f"Invalid assignment syntax: {assignment}")
110
+
111
+ inputs = [f.strip() for f in parts[0].split(",") if f.strip()]
112
+ outputs = [f.strip() for f in parts[1].split(",") if f.strip()]
113
+
114
+ return ParsedAssignment(
115
+ branch=branch,
116
+ inputs=inputs,
117
+ outputs=outputs,
118
+ resource=resource,
119
+ raw=assignment,
120
+ )
121
+
122
+
123
+ class Form(Element):
124
+ """Data binding container for work units.
125
+
126
+ A Form binds input data and tracks execution state. It can be created:
127
+ 1. From a Phrase (typed I/O)
128
+ 2. From an assignment string (dynamic fields)
129
+
130
+ Assignment DSL supports full format:
131
+ "branch: inputs -> outputs | resource"
132
+
133
+ Examples:
134
+ "a, b -> c" # Simple
135
+ "classifier: job -> role | api:fast" # Full with branch and resource
136
+ "writer: context -> summary" # Branch, no resource
137
+
138
+ Attributes:
139
+ assignment: DSL string 'branch: inputs -> outputs | resource'
140
+ branch: Worker/branch name for routing
141
+ resource: Resource hint for capability matching
142
+ input_fields: Fields required as inputs
143
+ output_fields: Fields produced as outputs
144
+ available_data: Current data values
145
+ output: Execution result
146
+ filled: Whether form has been executed
147
+ phrase: Optional Phrase reference for typed execution
148
+ """
149
+
150
+ assignment: str = Field(
151
+ default="",
152
+ description="Assignment DSL: 'branch: inputs -> outputs | resource'",
153
+ )
154
+ branch: str | None = Field(
155
+ default=None,
156
+ description="Worker/branch name for routing",
157
+ )
158
+ resource: str | None = Field(
159
+ default=None,
160
+ description="Resource hint (e.g., 'api:fast')",
161
+ )
162
+ input_fields: list[str] = Field(default_factory=list)
163
+ output_fields: list[str] = Field(default_factory=list)
164
+ available_data: dict[str, Any] = Field(default_factory=dict)
165
+ output: Any = Field(default=None)
166
+ filled: bool = Field(default=False)
167
+
168
+ # Optional phrase reference (set via from_phrase())
169
+ _phrase: "Phrase | None" = None
170
+
171
+ def model_post_init(self, _: Any) -> None:
172
+ """Parse assignment to derive fields if not already set."""
173
+ if self.assignment and not self.input_fields and not self.output_fields:
174
+ parsed = parse_full_assignment(self.assignment)
175
+ self.input_fields = parsed.inputs
176
+ self.output_fields = parsed.outputs
177
+ if parsed.branch and self.branch is None:
178
+ self.branch = parsed.branch
179
+ if parsed.resource and self.resource is None:
180
+ self.resource = parsed.resource
181
+
182
+ @classmethod
183
+ def from_phrase(
184
+ cls,
185
+ phrase: "Phrase",
186
+ **initial_data: Any,
187
+ ) -> "Form":
188
+ """Create Form from a Phrase with optional initial data.
189
+
190
+ Args:
191
+ phrase: Phrase defining typed I/O
192
+ **initial_data: Initial input values
193
+
194
+ Returns:
195
+ Form bound to the phrase
196
+ """
197
+ form = cls(
198
+ assignment=f"{', '.join(phrase.inputs)} -> {', '.join(phrase.outputs)}",
199
+ input_fields=list(phrase.inputs),
200
+ output_fields=list(phrase.outputs),
201
+ available_data=dict(initial_data),
202
+ )
203
+ form._phrase = phrase
204
+ return form
205
+
206
+ @property
207
+ def phrase(self) -> "Phrase | None":
208
+ """Get bound phrase if any."""
209
+ return self._phrase
210
+
211
+ def is_workable(self) -> bool:
212
+ """Check if form is ready for execution.
213
+
214
+ Returns:
215
+ True if all inputs available and not already filled
216
+ """
217
+ if self.filled:
218
+ return False
219
+
220
+ for field in self.input_fields:
221
+ if field not in self.available_data:
222
+ return False
223
+ if self.available_data[field] is None:
224
+ return False
225
+
226
+ return True
227
+
228
+ def get_inputs(self) -> dict[str, Any]:
229
+ """Extract input data for execution.
230
+
231
+ Returns:
232
+ Dict of input field values
233
+ """
234
+ return {
235
+ f: self.available_data[f]
236
+ for f in self.input_fields
237
+ if f in self.available_data
238
+ }
239
+
240
+ def fill(self, **data: Any) -> None:
241
+ """Add data to available_data.
242
+
243
+ Args:
244
+ **data: Field values to add
245
+ """
246
+ self.available_data.update(data)
247
+
248
+ def set_output(self, output: Any) -> None:
249
+ """Mark form as filled with output.
250
+
251
+ Args:
252
+ output: Execution result
253
+ """
254
+ self.output = output
255
+ self.filled = True
256
+
257
+ # Extract output field values from result
258
+ if output is not None:
259
+ for field in self.output_fields:
260
+ if hasattr(output, field):
261
+ self.available_data[field] = getattr(output, field)
262
+ elif isinstance(output, dict) and field in output:
263
+ self.available_data[field] = output[field]
264
+
265
+ def get_output_data(self) -> dict[str, Any]:
266
+ """Extract output field values.
267
+
268
+ Returns:
269
+ Dict mapping output field names to values
270
+ """
271
+ result = {}
272
+ for field in self.output_fields:
273
+ if field in self.available_data:
274
+ result[field] = self.available_data[field]
275
+ return result
276
+
277
+ async def execute(self, ctx: Any = None) -> Any:
278
+ """Execute the form if it has a bound phrase.
279
+
280
+ Args:
281
+ ctx: Execution context
282
+
283
+ Returns:
284
+ Execution result
285
+
286
+ Raises:
287
+ RuntimeError: If no phrase bound or form not workable
288
+ """
289
+ if self._phrase is None:
290
+ raise RuntimeError("Form has no bound phrase - cannot execute")
291
+
292
+ if not self.is_workable():
293
+ missing = [f for f in self.input_fields if f not in self.available_data]
294
+ raise RuntimeError(f"Form not workable - missing inputs: {missing}")
295
+
296
+ result = await self._phrase(self.get_inputs(), ctx)
297
+ self.set_output(result)
298
+ return result
299
+
300
+ def __repr__(self) -> str:
301
+ status = (
302
+ "filled" if self.filled else ("ready" if self.is_workable() else "pending")
303
+ )
304
+ phrase_info = f", phrase={self._phrase.name}" if self._phrase else ""
305
+ return f"Form('{self.assignment}', {status}{phrase_info})"
@@ -5,7 +5,7 @@
5
5
 
6
6
  Core types:
7
7
  Operation: Node + Event hybrid for graph-based execution.
8
- OperationRegistry: Per-session factory mapping.
8
+ OperationRegistry: Per-session handler mapping.
9
9
  OperationGraphBuilder (Builder): Fluent DAG construction.
10
10
 
11
11
  Execution:
@@ -16,17 +16,20 @@ Execution:
16
16
  from __future__ import annotations
17
17
 
18
18
  from .builder import Builder, OperationGraphBuilder
19
+ from .context import QueryFn, RequestContext
19
20
  from .flow import DependencyAwareExecutor, flow, flow_stream
20
- from .node import Operation, create_operation
21
- from .registry import OperationRegistry
21
+ from .node import Operation
22
+ from .registry import OperationHandler, OperationRegistry
22
23
 
23
24
  __all__ = (
24
25
  "Builder",
25
26
  "DependencyAwareExecutor",
26
27
  "Operation",
27
28
  "OperationGraphBuilder",
29
+ "OperationHandler",
28
30
  "OperationRegistry",
29
- "create_operation",
31
+ "QueryFn",
32
+ "RequestContext",
30
33
  "flow",
31
34
  "flow_stream",
32
35
  )
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any
13
13
  from uuid import UUID
14
14
 
15
15
  from krons.core import Edge, Graph
16
- from krons.types import Undefined, UndefinedType, is_sentinel, not_sentinel
16
+ from krons.core.types import Undefined, UndefinedType, is_sentinel, not_sentinel
17
17
  from krons.utils._utils import to_uuid
18
18
 
19
19
  from .node import Operation
@@ -11,8 +11,8 @@ from datetime import datetime
11
11
  from typing import TYPE_CHECKING, Any, Protocol
12
12
  from uuid import UUID, uuid4
13
13
 
14
- from krons.types.base import DataClass
15
- from krons.types.identity import ID
14
+ from krons.core.types.base import DataClass
15
+ from krons.core.types.identity import ID
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from krons.session import Branch, Session
@@ -85,7 +85,7 @@ class RequestContext(DataClass):
85
85
  name: str
86
86
  id: UUID = field(default_factory=uuid4)
87
87
  session_id: ID[Session] | None = None
88
- branch_id: ID[Branch] | None = None
88
+ branch: ID[Branch] | str | None = None
89
89
  metadata: dict[str, Any] = field(default_factory=dict)
90
90
  conn: Any | None = None
91
91
  query_fn: QueryFn | None = None
@@ -95,7 +95,7 @@ class RequestContext(DataClass):
95
95
  self,
96
96
  name: str,
97
97
  session_id: ID[Session] | None = None,
98
- branch_id: ID[Branch] | None = None,
98
+ branch: ID[Branch] | str | None = None,
99
99
  id: UUID | None = None,
100
100
  conn: Any | None = None,
101
101
  query_fn: QueryFn | None = None,
@@ -105,7 +105,7 @@ class RequestContext(DataClass):
105
105
  self.name = name
106
106
  self.id = id or uuid4()
107
107
  self.session_id = session_id
108
- self.branch_id = branch_id
108
+ self.branch = branch
109
109
  self.conn = conn
110
110
  self.query_fn = query_fn
111
111
  self.now = now
@@ -127,3 +127,34 @@ class RequestContext(DataClass):
127
127
  raise AttributeError(
128
128
  f"'{type(self).__name__}' has no attribute '{name}'"
129
129
  ) from None
130
+
131
+ async def get_session(self) -> Session | None:
132
+ """Get the Session object.
133
+
134
+ Checks bound reference first (set by Operation.invoke),
135
+ then falls back to global registry lookup via session_id.
136
+
137
+ Returns None if no session available.
138
+ """
139
+ if "_bound_session" in self.metadata:
140
+ return self.metadata["_bound_session"]
141
+ if self.session_id is None:
142
+ return None
143
+ from krons.session.registry import get_session
144
+
145
+ return await get_session(self.session_id)
146
+
147
+ async def get_branch(self) -> Branch | None:
148
+ """Get the Branch object.
149
+
150
+ Checks bound reference first (set by Operation.invoke),
151
+ then falls back to session lookup via branch identifier.
152
+
153
+ Returns None if no branch available.
154
+ """
155
+ if "_bound_branch" in self.metadata:
156
+ return self.metadata["_bound_branch"]
157
+ session = await self.get_session()
158
+ if session is None or self.branch is None:
159
+ return None
160
+ return session.branches.get(self.branch)
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any
16
16
  from uuid import UUID
17
17
 
18
18
  from krons.core import EventStatus, Graph
19
- from krons.types import Undefined, UndefinedType, is_sentinel
19
+ from krons.core.types import Undefined, UndefinedType, is_sentinel
20
20
  from krons.utils import concurrency
21
21
  from krons.utils.concurrency import CapacityLimiter, CompletionStream
22
22
 
@@ -94,8 +94,12 @@ class DependencyAwareExecutor:
94
94
  """
95
95
  self.session = session
96
96
  self.graph = graph
97
- resolved_max_concurrent = None if is_sentinel(max_concurrent) else max_concurrent
98
- resolved_default_branch = None if is_sentinel(default_branch) else default_branch
97
+ resolved_max_concurrent = (
98
+ None if is_sentinel(max_concurrent) else max_concurrent
99
+ )
100
+ resolved_default_branch = (
101
+ None if is_sentinel(default_branch) else default_branch
102
+ )
99
103
  self.max_concurrent = resolved_max_concurrent
100
104
  self.stop_on_error = stop_on_error
101
105
  self.verbose = verbose
@@ -107,7 +111,9 @@ class DependencyAwareExecutor:
107
111
  self.operation_branches: dict[UUID, Branch | None] = {}
108
112
 
109
113
  self._limiter: CapacityLimiter | None = (
110
- CapacityLimiter(resolved_max_concurrent) if resolved_max_concurrent else None
114
+ CapacityLimiter(resolved_max_concurrent)
115
+ if resolved_max_concurrent
116
+ else None
111
117
  )
112
118
 
113
119
  for node in graph.nodes:
@@ -258,7 +264,9 @@ class DependencyAwareExecutor:
258
264
  self.operation_branches[node.id] = default_branch
259
265
 
260
266
  if self.verbose:
261
- logger.debug("Pre-allocated branches for %d operations", len(self.operation_branches))
267
+ logger.debug(
268
+ "Pre-allocated branches for %d operations", len(self.operation_branches)
269
+ )
262
270
 
263
271
  async def _execute_operation(self, operation: Operation) -> Operation:
264
272
  """Execute single operation: wait deps -> acquire slot -> invoke -> signal."""
@@ -1,5 +1,13 @@
1
1
  # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
2
  # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Operation: executable graph node bridging session to handler.
5
+
6
+ Operation.invoke() creates a RequestContext from the bound session/branch
7
+ and calls the registered handler with (params, ctx). Handlers never need
8
+ to know about the factory pattern — they receive a clean RequestContext.
9
+ """
10
+
3
11
  from __future__ import annotations
4
12
 
5
13
  from typing import TYPE_CHECKING, Any
@@ -7,23 +15,36 @@ from typing import TYPE_CHECKING, Any
7
15
  from pydantic import Field, PrivateAttr
8
16
 
9
17
  from krons.core import Event, Node
10
- from krons.types import Undefined, UndefinedType, is_sentinel
18
+
19
+ from .context import RequestContext
11
20
 
12
21
  if TYPE_CHECKING:
13
22
  from krons.session import Branch, Session
14
23
 
15
- __all__ = ("Operation", "create_operation")
24
+ __all__ = ("Operation",)
16
25
 
17
26
 
18
27
  class Operation(Node, Event):
28
+ """Executable operation node.
29
+
30
+ Bridges session.conduct() to handler(params, ctx) by:
31
+ 1. Storing bound session/branch references
32
+ 2. Creating RequestContext with those references
33
+ 3. Looking up the handler from session.operations registry
34
+ 4. Calling handler(params, ctx)
35
+
36
+ The result is stored in execution.response (via Event.invoke).
37
+ """
38
+
19
39
  operation_type: str
20
40
  parameters: dict[str, Any] | Any = Field(
21
41
  default_factory=dict,
22
- description="Operation parameters (dict or Pydantic model)",
42
+ description="Operation parameters (Params dataclass, dict, or model)",
23
43
  )
24
44
 
25
45
  _session: Any = PrivateAttr(default=None)
26
46
  _branch: Any = PrivateAttr(default=None)
47
+ _verbose: bool = PrivateAttr(default=False)
27
48
 
28
49
  def bind(self, session: Session, branch: Branch) -> Operation:
29
50
  """Bind session and branch for execution.
@@ -31,11 +52,11 @@ class Operation(Node, Event):
31
52
  Must be called before invoke() if not using Session.conduct().
32
53
 
33
54
  Args:
34
- session: Session with operations registry and services
35
- branch: Branch for message context
55
+ session: Session with operations registry and services.
56
+ branch: Branch for message context.
36
57
 
37
58
  Returns:
38
- Self for chaining
59
+ Self for chaining.
39
60
  """
40
61
  self._session = session
41
62
  self._branch = branch
@@ -51,51 +72,32 @@ class Operation(Node, Event):
51
72
  return self._session, self._branch
52
73
 
53
74
  async def _invoke(self) -> Any:
54
- """Execute via session's operation registry. Called by Event.invoke().
75
+ """Execute handler via session's operation registry.
76
+
77
+ Creates a RequestContext with bound session/branch references
78
+ and calls handler(params, ctx). Called by Event.invoke().
55
79
 
56
80
  Returns:
57
- Factory result (stored in execution.response).
81
+ Handler result (stored in execution.response).
58
82
 
59
83
  Raises:
60
84
  RuntimeError: If not bound.
61
85
  KeyError: If operation_type not registered.
62
86
  """
63
87
  session, branch = self._require_binding()
64
- factory = session.operations.get(self.operation_type)
65
- return await factory(session, branch, self.parameters)
66
-
67
- def __repr__(self) -> str:
68
- bound = "bound" if self._session is not None else "unbound"
69
- return (
70
- f"Operation(type={self.operation_type}, status={self.execution.status.value}, {bound})"
88
+ handler = session.operations.get(self.operation_type)
89
+
90
+ ctx = RequestContext(
91
+ name=self.operation_type,
92
+ session_id=session.id,
93
+ branch=branch.name or str(branch.id),
94
+ _bound_session=session,
95
+ _bound_branch=branch,
96
+ _verbose=self._verbose,
71
97
  )
72
98
 
99
+ return await handler(self.parameters, ctx)
73
100
 
74
- def create_operation(
75
- operation_type: str | UndefinedType = Undefined,
76
- parameters: dict[str, Any] | UndefinedType = Undefined,
77
- **kwargs,
78
- ) -> Operation:
79
- """Factory for Operation nodes.
80
-
81
- Args:
82
- operation_type: Registry key (required).
83
- parameters: Factory arguments dict (default: {}).
84
- **kwargs: Additional fields (metadata, timeout, etc.).
85
-
86
- Returns:
87
- Unbound Operation ready for bind() and invoke().
88
-
89
- Raises:
90
- ValueError: If operation_type not provided.
91
- """
92
- if is_sentinel(operation_type):
93
- raise ValueError("operation_type is required")
94
-
95
- resolved_params: dict[str, Any] = {} if is_sentinel(parameters) else parameters
96
-
97
- return Operation(
98
- operation_type=operation_type,
99
- parameters=resolved_params,
100
- **kwargs,
101
- )
101
+ def __repr__(self) -> str:
102
+ bound = "bound" if self._session is not None else "unbound"
103
+ return f"Operation(type={self.operation_type}, status={self.execution.status.value}, {bound})"