krons 0.1.0__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 (162) 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 +127 -0
  37. krons/core/base/__init__.py +121 -0
  38. {kronos/core → krons/core/base}/broadcaster.py +7 -3
  39. {kronos/core → krons/core/base}/element.py +15 -7
  40. {kronos/core → krons/core/base}/event.py +41 -8
  41. {kronos/core → krons/core/base}/eventbus.py +4 -2
  42. {kronos/core → krons/core/base}/flow.py +14 -7
  43. {kronos/core → krons/core/base}/graph.py +27 -11
  44. {kronos/core → krons/core/base}/node.py +47 -22
  45. {kronos/core → krons/core/base}/pile.py +26 -12
  46. {kronos/core → krons/core/base}/processor.py +23 -9
  47. {kronos/core → krons/core/base}/progression.py +5 -3
  48. {kronos → krons/core}/specs/__init__.py +0 -5
  49. {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
  50. {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
  51. {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
  52. {kronos → krons/core}/specs/catalog/__init__.py +2 -2
  53. {kronos → krons/core}/specs/catalog/_audit.py +3 -3
  54. {kronos → krons/core}/specs/catalog/_common.py +2 -2
  55. {kronos → krons/core}/specs/catalog/_content.py +5 -5
  56. {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
  57. {kronos → krons/core}/specs/factory.py +7 -7
  58. {kronos → krons/core}/specs/operable.py +9 -3
  59. {kronos → krons/core}/specs/protocol.py +4 -2
  60. {kronos → krons/core}/specs/spec.py +25 -13
  61. {kronos → krons/core}/types/base.py +7 -5
  62. {kronos → krons/core}/types/db_types.py +2 -2
  63. {kronos → krons/core}/types/identity.py +1 -1
  64. {kronos → krons}/errors.py +13 -13
  65. {kronos → krons}/protocols.py +9 -4
  66. krons/resource/__init__.py +89 -0
  67. {kronos/services → krons/resource}/backend.py +50 -24
  68. {kronos/services → krons/resource}/endpoint.py +28 -14
  69. {kronos/services → krons/resource}/hook.py +22 -9
  70. {kronos/services → krons/resource}/imodel.py +50 -32
  71. {kronos/services → krons/resource}/registry.py +27 -25
  72. {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
  73. {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
  74. {kronos/services → krons/resource}/utilities/resilience.py +17 -7
  75. krons/resource/utilities/token_calculator.py +185 -0
  76. {kronos → krons}/session/__init__.py +12 -17
  77. krons/session/constraints.py +70 -0
  78. {kronos → krons}/session/exchange.py +14 -6
  79. {kronos → krons}/session/message.py +4 -2
  80. krons/session/registry.py +35 -0
  81. {kronos → krons}/session/session.py +165 -174
  82. krons/utils/__init__.py +85 -0
  83. krons/utils/_function_arg_parser.py +99 -0
  84. krons/utils/_pythonic_function_call.py +249 -0
  85. {kronos → krons}/utils/_to_list.py +9 -3
  86. {kronos → krons}/utils/_utils.py +9 -5
  87. {kronos → krons}/utils/concurrency/__init__.py +38 -38
  88. {kronos → krons}/utils/concurrency/_async_call.py +6 -4
  89. {kronos → krons}/utils/concurrency/_errors.py +3 -1
  90. {kronos → krons}/utils/concurrency/_patterns.py +3 -1
  91. {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
  92. krons/utils/display.py +257 -0
  93. {kronos → krons}/utils/fuzzy/__init__.py +6 -1
  94. {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
  95. {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
  96. {kronos → krons}/utils/fuzzy/_to_dict.py +3 -1
  97. krons/utils/schemas/__init__.py +26 -0
  98. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  99. krons/utils/schemas/_formatter.py +72 -0
  100. krons/utils/schemas/_minimal_yaml.py +151 -0
  101. krons/utils/schemas/_typescript.py +153 -0
  102. {kronos → krons}/utils/sql/_sql_validation.py +1 -1
  103. krons/utils/validators/__init__.py +3 -0
  104. krons/utils/validators/_validate_image_url.py +56 -0
  105. krons/work/__init__.py +126 -0
  106. krons/work/engine.py +333 -0
  107. krons/work/form.py +305 -0
  108. {kronos → krons/work}/operations/__init__.py +7 -4
  109. {kronos → krons/work}/operations/builder.py +4 -4
  110. {kronos/enforcement → krons/work/operations}/context.py +37 -6
  111. {kronos → krons/work}/operations/flow.py +17 -9
  112. krons/work/operations/node.py +103 -0
  113. krons/work/operations/registry.py +103 -0
  114. {kronos/specs → krons/work}/phrase.py +131 -14
  115. {kronos/enforcement → krons/work}/policy.py +3 -3
  116. krons/work/report.py +268 -0
  117. krons/work/rules/__init__.py +47 -0
  118. {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
  119. {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
  120. {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
  121. {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
  122. {kronos/enforcement → krons/work/rules}/rule.py +2 -2
  123. {kronos/enforcement → krons/work/rules}/validator.py +21 -6
  124. {kronos/enforcement → krons/work}/service.py +16 -7
  125. krons/work/worker.py +266 -0
  126. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
  127. krons-0.2.0.dist-info/RECORD +154 -0
  128. kronos/core/__init__.py +0 -145
  129. kronos/enforcement/__init__.py +0 -57
  130. kronos/operations/node.py +0 -101
  131. kronos/operations/registry.py +0 -92
  132. kronos/services/__init__.py +0 -81
  133. kronos/specs/adapters/__init__.py +0 -0
  134. kronos/utils/__init__.py +0 -40
  135. krons-0.1.0.dist-info/RECORD +0 -101
  136. {kronos → krons/core/specs/adapters}/__init__.py +0 -0
  137. {kronos → krons/core}/specs/adapters/_utils.py +0 -0
  138. {kronos → krons/core}/specs/adapters/factory.py +0 -0
  139. {kronos → krons/core}/types/__init__.py +0 -0
  140. {kronos → krons/core}/types/_sentinel.py +0 -0
  141. {kronos → krons}/py.typed +0 -0
  142. {kronos/services → krons/resource}/utilities/__init__.py +0 -0
  143. {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
  144. {kronos → krons}/utils/_hash.py +0 -0
  145. {kronos → krons}/utils/_json_dump.py +0 -0
  146. {kronos → krons}/utils/_lazy_init.py +0 -0
  147. {kronos → krons}/utils/_to_num.py +0 -0
  148. {kronos → krons}/utils/concurrency/_cancel.py +0 -0
  149. {kronos → krons}/utils/concurrency/_primitives.py +0 -0
  150. {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
  151. {kronos → krons}/utils/concurrency/_run_async.py +0 -0
  152. {kronos → krons}/utils/concurrency/_task.py +0 -0
  153. {kronos → krons}/utils/concurrency/_utils.py +0 -0
  154. {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
  155. {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
  156. {kronos → krons}/utils/sql/__init__.py +0 -0
  157. {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
  158. {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
  159. {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
  160. {kronos/enforcement → krons/work/rules}/registry.py +0 -0
  161. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  162. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
krons/work/engine.py ADDED
@@ -0,0 +1,333 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """WorkerEngine - Execution driver for Worker workflows.
5
+
6
+ The engine manages task execution, following worklinks to traverse the
7
+ workflow graph defined by @work and @worklink decorators.
8
+
9
+ Example:
10
+ worker = FileCoder()
11
+ engine = WorkerEngine(worker=worker, refresh_time=0.3)
12
+
13
+ # Add a task starting at a specific function
14
+ task = await engine.add_task(
15
+ form=my_form,
16
+ task_function="start_task",
17
+ task_max_steps=20,
18
+ )
19
+
20
+ # Run until all tasks complete
21
+ await engine.execute()
22
+
23
+ # Or run indefinitely
24
+ await engine.execute_lasting()
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ from dataclasses import dataclass, field
31
+ from typing import TYPE_CHECKING, Any
32
+ from uuid import UUID, uuid4
33
+
34
+ from krons.utils import concurrency
35
+
36
+ if TYPE_CHECKING:
37
+ from .worker import Worker
38
+
39
+ __all__ = ("WorkerEngine", "WorkerTask")
40
+
41
+
42
+ @dataclass
43
+ class WorkerTask:
44
+ """A task being executed by the engine.
45
+
46
+ Attributes:
47
+ id: Unique task identifier
48
+ function: Current method name to execute
49
+ kwargs: Arguments for the method
50
+ status: PENDING, PROCESSING, COMPLETED, FAILED
51
+ result: Final result when completed
52
+ error: Exception if failed
53
+ max_steps: Max workflow steps before stopping
54
+ current_step: Current step count
55
+ history: List of (function, result) tuples for debugging
56
+ """
57
+
58
+ id: UUID = field(default_factory=uuid4)
59
+ function: str = ""
60
+ kwargs: dict[str, Any] = field(default_factory=dict)
61
+ status: str = "PENDING"
62
+ result: Any = None
63
+ error: Exception | None = None
64
+ max_steps: int = 100
65
+ current_step: int = 0
66
+ history: list[tuple[str, Any]] = field(default_factory=list)
67
+
68
+
69
+ class WorkerEngine:
70
+ """Execution driver for Worker workflows.
71
+
72
+ Manages a queue of tasks, executing them through the workflow graph
73
+ defined by the worker's @work and @worklink decorators.
74
+
75
+ Attributes:
76
+ worker: The Worker instance to execute
77
+ refresh_time: Seconds between processing cycles
78
+ tasks: Dict of active tasks by ID
79
+ _task_queue: Async queue for pending work
80
+ _stopped: Stop flag
81
+
82
+ Example:
83
+ engine = WorkerEngine(worker=my_worker)
84
+ task = await engine.add_task(
85
+ form=my_form,
86
+ task_function="entry_point",
87
+ )
88
+ await engine.execute()
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ worker: Worker,
94
+ refresh_time: float = 0.1,
95
+ max_concurrent: int = 10,
96
+ ) -> None:
97
+ """Initialize the engine.
98
+
99
+ Args:
100
+ worker: Worker instance with @work/@worklink methods
101
+ refresh_time: Seconds between processing cycles
102
+ max_concurrent: Max concurrent task executions
103
+ """
104
+ self.worker = worker
105
+ self.refresh_time = refresh_time
106
+ self.max_concurrent = max_concurrent
107
+
108
+ self.tasks: dict[UUID, WorkerTask] = {}
109
+ self._task_queue: asyncio.Queue[UUID] = asyncio.Queue()
110
+ self._stopped = False
111
+ self._semaphore = asyncio.Semaphore(max_concurrent)
112
+
113
+ async def add_task(
114
+ self,
115
+ task_function: str,
116
+ task_max_steps: int = 100,
117
+ **kwargs: Any,
118
+ ) -> WorkerTask:
119
+ """Add a new task to the execution queue.
120
+
121
+ Args:
122
+ task_function: Entry method name to start execution
123
+ task_max_steps: Max workflow steps before stopping
124
+ **kwargs: Arguments for the entry method
125
+
126
+ Returns:
127
+ WorkerTask instance (can be monitored for status)
128
+
129
+ Raises:
130
+ ValueError: If task_function not found in worker
131
+ """
132
+ if task_function not in self.worker._work_methods:
133
+ raise ValueError(
134
+ f"Method '{task_function}' not found. "
135
+ f"Available: {list(self.worker._work_methods.keys())}"
136
+ )
137
+
138
+ task = WorkerTask(
139
+ function=task_function,
140
+ kwargs=kwargs,
141
+ max_steps=task_max_steps,
142
+ )
143
+ self.tasks[task.id] = task
144
+ await self._task_queue.put(task.id)
145
+
146
+ return task
147
+
148
+ async def execute(self) -> None:
149
+ """Execute all queued tasks until queue is empty.
150
+
151
+ Processes tasks through their workflow graphs, following worklinks.
152
+ Returns when all tasks are completed or failed.
153
+ """
154
+ self._stopped = False
155
+ await self.worker.start()
156
+
157
+ while not self._stopped and not self._task_queue.empty():
158
+ await self._process_cycle()
159
+ await concurrency.sleep(self.refresh_time)
160
+
161
+ async def execute_lasting(self) -> None:
162
+ """Execute indefinitely until stop() is called.
163
+
164
+ Useful for long-running worker services that continuously
165
+ process incoming tasks.
166
+ """
167
+ self._stopped = False
168
+ await self.worker.start()
169
+
170
+ while not self._stopped:
171
+ await self._process_cycle()
172
+ await concurrency.sleep(self.refresh_time)
173
+
174
+ async def stop(self) -> None:
175
+ """Stop the execution loop."""
176
+ self._stopped = True
177
+ await self.worker.stop()
178
+
179
+ async def _process_cycle(self) -> None:
180
+ """Process one cycle of tasks."""
181
+ # Collect tasks to process this cycle
182
+ tasks_to_process: list[UUID] = []
183
+
184
+ while (
185
+ not self._task_queue.empty() and len(tasks_to_process) < self.max_concurrent
186
+ ):
187
+ try:
188
+ task_id = self._task_queue.get_nowait()
189
+ tasks_to_process.append(task_id)
190
+ except asyncio.QueueEmpty:
191
+ break
192
+
193
+ if not tasks_to_process:
194
+ return
195
+
196
+ # Process tasks concurrently
197
+ async with concurrency.create_task_group() as tg:
198
+ for task_id in tasks_to_process:
199
+ tg.start_soon(self._process_task, task_id)
200
+
201
+ async def _process_task(self, task_id: UUID) -> None:
202
+ """Process a single task through one workflow step."""
203
+ async with self._semaphore:
204
+ task = self.tasks.get(task_id)
205
+ if task is None or task.status in ("COMPLETED", "FAILED"):
206
+ return
207
+
208
+ # Check step limit
209
+ if task.current_step >= task.max_steps:
210
+ task.status = "COMPLETED"
211
+ return
212
+
213
+ task.status = "PROCESSING"
214
+ task.current_step += 1
215
+
216
+ try:
217
+ # Get the work method and config
218
+ method, config = self.worker._work_methods[task.function]
219
+
220
+ # Prepare kwargs with form binding
221
+ call_kwargs = dict(task.kwargs)
222
+ if config.form_param_key and config.assignment:
223
+ form_id = call_kwargs.get(config.form_param_key)
224
+ if form_id and form_id in self.worker.forms:
225
+ form = self.worker.forms[form_id]
226
+ # Bind input fields from form to kwargs
227
+ for input_field in form.input_fields:
228
+ if input_field in form.available_data:
229
+ call_kwargs[input_field] = form.available_data[
230
+ input_field
231
+ ]
232
+
233
+ # Execute with optional timeout
234
+ if config.timeout:
235
+ result = await asyncio.wait_for(
236
+ method(**call_kwargs),
237
+ timeout=config.timeout,
238
+ )
239
+ else:
240
+ result = await method(**call_kwargs)
241
+
242
+ # Record history
243
+ task.history.append((task.function, result))
244
+ task.result = result
245
+
246
+ # Follow worklinks
247
+ next_tasks = await self._follow_links(task, result)
248
+
249
+ if next_tasks:
250
+ # Continue with next step(s)
251
+ for next_func, next_kwargs in next_tasks:
252
+ task.function = next_func
253
+ task.kwargs = next_kwargs
254
+ task.status = "PENDING"
255
+ await self._task_queue.put(task_id)
256
+ break # Only follow first matching link for now
257
+ else:
258
+ # No more links - task complete
259
+ task.status = "COMPLETED"
260
+
261
+ except Exception as e:
262
+ task.status = "FAILED"
263
+ task.error = e
264
+
265
+ async def _follow_links(
266
+ self, task: WorkerTask, result: Any
267
+ ) -> list[tuple[str, dict[str, Any]]]:
268
+ """Follow worklinks from current method.
269
+
270
+ Args:
271
+ task: Current task
272
+ result: Result from current method
273
+
274
+ Returns:
275
+ List of (next_function, kwargs) tuples for matching links
276
+ """
277
+ next_tasks: list[tuple[str, dict[str, Any]]] = []
278
+
279
+ for link in self.worker.get_links_from(task.function):
280
+ try:
281
+ # Get the handler method from worker and call it
282
+ handler = getattr(self.worker, link.handler_name)
283
+ next_kwargs = await handler(result)
284
+
285
+ # None means skip this edge
286
+ if next_kwargs is not None:
287
+ next_tasks.append((link.to_, next_kwargs))
288
+
289
+ except Exception:
290
+ # Link handler failed - skip this edge
291
+ continue
292
+
293
+ return next_tasks
294
+
295
+ def get_task(self, task_id: UUID) -> WorkerTask | None:
296
+ """Get task by ID."""
297
+ return self.tasks.get(task_id)
298
+
299
+ def get_tasks_by_status(self, status: str) -> list[WorkerTask]:
300
+ """Get all tasks with given status."""
301
+ return [t for t in self.tasks.values() if t.status == status]
302
+
303
+ @property
304
+ def pending_tasks(self) -> list[WorkerTask]:
305
+ """Tasks waiting to be processed."""
306
+ return self.get_tasks_by_status("PENDING")
307
+
308
+ @property
309
+ def processing_tasks(self) -> list[WorkerTask]:
310
+ """Tasks currently being processed."""
311
+ return self.get_tasks_by_status("PROCESSING")
312
+
313
+ @property
314
+ def completed_tasks(self) -> list[WorkerTask]:
315
+ """Tasks that completed successfully."""
316
+ return self.get_tasks_by_status("COMPLETED")
317
+
318
+ @property
319
+ def failed_tasks(self) -> list[WorkerTask]:
320
+ """Tasks that failed with errors."""
321
+ return self.get_tasks_by_status("FAILED")
322
+
323
+ def status_counts(self) -> dict[str, int]:
324
+ """Count tasks by status."""
325
+ counts: dict[str, int] = {}
326
+ for task in self.tasks.values():
327
+ counts[task.status] = counts.get(task.status, 0) + 1
328
+ return counts
329
+
330
+ def __repr__(self) -> str:
331
+ counts = self.status_counts()
332
+ total = len(self.tasks)
333
+ return f"WorkerEngine(worker={self.worker.name}, tasks={total}, {counts})"
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
  )
@@ -12,14 +12,14 @@ from __future__ import annotations
12
12
  from typing import TYPE_CHECKING, Any
13
13
  from uuid import UUID
14
14
 
15
- from kronos.core import Edge, Graph
16
- from kronos.types import Undefined, UndefinedType, is_sentinel, not_sentinel
17
- from kronos.utils._utils import to_uuid
15
+ from krons.core import Edge, Graph
16
+ from krons.core.types import Undefined, UndefinedType, is_sentinel, not_sentinel
17
+ from krons.utils._utils import to_uuid
18
18
 
19
19
  from .node import Operation
20
20
 
21
21
  if TYPE_CHECKING:
22
- from kronos.session import Branch
22
+ from krons.session import Branch
23
23
 
24
24
  __all__ = ("Builder", "OperationGraphBuilder")
25
25