krons 0.1.1__py3-none-any.whl → 0.2.1__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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +56 -74
- krons/core/base/__init__.py +121 -0
- krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
- krons/core/{element.py → base/element.py} +13 -5
- krons/core/{event.py → base/event.py} +39 -6
- krons/core/{eventbus.py → base/eventbus.py} +3 -1
- krons/core/{flow.py → base/flow.py} +11 -4
- krons/core/{graph.py → base/graph.py} +24 -8
- krons/core/{node.py → base/node.py} +44 -19
- krons/core/{pile.py → base/pile.py} +22 -8
- krons/core/{processor.py → base/processor.py} +21 -7
- krons/core/{progression.py → base/progression.py} +3 -1
- krons/{specs → core/specs}/__init__.py +0 -5
- krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
- krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
- krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
- krons/{specs → core/specs}/catalog/__init__.py +2 -2
- krons/{specs → core/specs}/catalog/_audit.py +2 -2
- krons/{specs → core/specs}/catalog/_common.py +2 -2
- krons/{specs → core/specs}/catalog/_content.py +4 -4
- krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
- krons/{specs → core/specs}/factory.py +5 -5
- krons/{specs → core/specs}/operable.py +8 -2
- krons/{specs → core/specs}/protocol.py +4 -2
- krons/{specs → core/specs}/spec.py +23 -11
- krons/{types → core/types}/base.py +4 -2
- krons/{types → core/types}/db_types.py +2 -2
- krons/errors.py +13 -13
- krons/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- krons/{services → resource}/backend.py +48 -22
- krons/{services → resource}/endpoint.py +28 -14
- krons/{services → resource}/hook.py +20 -7
- krons/{services → resource}/imodel.py +46 -28
- krons/{services → resource}/registry.py +26 -24
- krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
- krons/{services → resource}/utilities/rate_limiter.py +3 -1
- krons/{services → resource}/utilities/resilience.py +15 -5
- krons/resource/utilities/token_calculator.py +185 -0
- krons/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- krons/session/exchange.py +11 -3
- krons/session/message.py +3 -1
- krons/session/registry.py +35 -0
- krons/session/session.py +165 -174
- krons/utils/__init__.py +45 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- krons/utils/_to_list.py +9 -3
- krons/utils/_utils.py +6 -2
- krons/utils/concurrency/_async_call.py +4 -2
- krons/utils/concurrency/_errors.py +3 -1
- krons/utils/concurrency/_patterns.py +3 -1
- krons/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- krons/utils/fuzzy/__init__.py +6 -1
- krons/utils/fuzzy/_fuzzy_match.py +14 -8
- krons/utils/fuzzy/_string_similarity.py +3 -1
- krons/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +115 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +242 -0
- krons/{operations → work/operations}/__init__.py +7 -4
- krons/{operations → work/operations}/builder.py +1 -1
- krons/{enforcement → work/operations}/context.py +36 -5
- krons/{operations → work/operations}/flow.py +13 -5
- krons/{operations → work/operations}/node.py +45 -43
- krons/work/operations/registry.py +103 -0
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- krons/{enforcement → work/rules}/common/boolean.py +3 -1
- krons/{enforcement → work/rules}/common/choice.py +9 -3
- krons/{enforcement → work/rules}/common/number.py +3 -1
- krons/{enforcement → work/rules}/common/string.py +9 -3
- krons/{enforcement → work/rules}/rule.py +1 -1
- krons/{enforcement → work/rules}/validator.py +20 -5
- krons/work/worker.py +266 -0
- {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
- krons-0.2.1.dist-info/RECORD +151 -0
- krons/enforcement/__init__.py +0 -57
- krons/enforcement/policy.py +0 -80
- krons/enforcement/service.py +0 -370
- krons/operations/registry.py +0 -92
- krons/services/__init__.py +0 -81
- krons/specs/phrase.py +0 -405
- krons-0.1.1.dist-info/RECORD +0 -101
- /krons/{specs → core/specs}/adapters/__init__.py +0 -0
- /krons/{specs → core/specs}/adapters/_utils.py +0 -0
- /krons/{specs → core/specs}/adapters/factory.py +0 -0
- /krons/{types → core/types}/__init__.py +0 -0
- /krons/{types → core/types}/_sentinel.py +0 -0
- /krons/{types → core/types}/identity.py +0 -0
- /krons/{services → resource}/utilities/__init__.py +0 -0
- /krons/{services → resource}/utilities/header_factory.py +0 -0
- /krons/{enforcement → work/rules}/common/__init__.py +0 -0
- /krons/{enforcement → work/rules}/common/mapping.py +0 -0
- /krons/{enforcement → work/rules}/common/model.py +0 -0
- /krons/{enforcement → work/rules}/registry.py +0 -0
- {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
- {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Core types:
|
|
7
7
|
Operation: Node + Event hybrid for graph-based execution.
|
|
8
|
-
OperationRegistry: Per-session
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
98
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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",
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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})"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Per-session operation handler registry.
|
|
5
|
+
|
|
6
|
+
Maps operation names to async handlers. Instantiated per-Session
|
|
7
|
+
for isolation, testability, and per-session customization.
|
|
8
|
+
|
|
9
|
+
Handler signature: async handler(params, ctx: RequestContext) -> result
|
|
10
|
+
Operation._invoke() creates the RequestContext from bound session/branch.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
__all__ = ("OperationHandler", "OperationRegistry")
|
|
19
|
+
|
|
20
|
+
OperationHandler = Callable[..., Awaitable[Any]]
|
|
21
|
+
"""Handler signature: async (params, ctx: RequestContext) -> result"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OperationRegistry:
|
|
25
|
+
"""Map operation names to async handler functions.
|
|
26
|
+
|
|
27
|
+
Per-session registry (not global) for isolation and testability.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
from krons.agent.operations import generate, structure, operate
|
|
31
|
+
|
|
32
|
+
registry = OperationRegistry()
|
|
33
|
+
registry.register("generate", generate)
|
|
34
|
+
registry.register("structure", structure)
|
|
35
|
+
registry.register("operate", operate)
|
|
36
|
+
|
|
37
|
+
# Called by Operation._invoke() — users call session.conduct()
|
|
38
|
+
handler = registry.get("generate")
|
|
39
|
+
result = await handler(params, ctx)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
"""Initialize empty registry."""
|
|
44
|
+
self._handlers: dict[str, OperationHandler] = {}
|
|
45
|
+
|
|
46
|
+
def register(
|
|
47
|
+
self,
|
|
48
|
+
operation_name: str,
|
|
49
|
+
handler: OperationHandler,
|
|
50
|
+
*,
|
|
51
|
+
override: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Register handler for operation name.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
operation_name: Lookup key (e.g. "generate", "operate").
|
|
57
|
+
handler: Async (params, ctx) -> result.
|
|
58
|
+
override: Allow replacing existing. Default False.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If name exists and override=False.
|
|
62
|
+
"""
|
|
63
|
+
if operation_name in self._handlers and not override:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Operation '{operation_name}' already registered. "
|
|
66
|
+
"Use override=True to replace."
|
|
67
|
+
)
|
|
68
|
+
self._handlers[operation_name] = handler
|
|
69
|
+
|
|
70
|
+
def get(self, operation_name: str) -> OperationHandler:
|
|
71
|
+
"""Get handler by name. Raises KeyError with available names if not found."""
|
|
72
|
+
if operation_name not in self._handlers:
|
|
73
|
+
raise KeyError(
|
|
74
|
+
f"Operation '{operation_name}' not registered. "
|
|
75
|
+
f"Available: {self.list_names()}"
|
|
76
|
+
)
|
|
77
|
+
return self._handlers[operation_name]
|
|
78
|
+
|
|
79
|
+
def has(self, operation_name: str) -> bool:
|
|
80
|
+
"""Check if name is registered."""
|
|
81
|
+
return operation_name in self._handlers
|
|
82
|
+
|
|
83
|
+
def unregister(self, operation_name: str) -> bool:
|
|
84
|
+
"""Remove registration. Returns True if existed."""
|
|
85
|
+
if operation_name in self._handlers:
|
|
86
|
+
del self._handlers[operation_name]
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def list_names(self) -> list[str]:
|
|
91
|
+
"""Return all registered operation names."""
|
|
92
|
+
return list(self._handlers.keys())
|
|
93
|
+
|
|
94
|
+
def __contains__(self, operation_name: str) -> bool:
|
|
95
|
+
"""Support 'name in registry' syntax."""
|
|
96
|
+
return operation_name in self._handlers
|
|
97
|
+
|
|
98
|
+
def __len__(self) -> int:
|
|
99
|
+
"""Count of registered operations."""
|
|
100
|
+
return len(self._handlers)
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f"OperationRegistry(operations={self.list_names()})"
|
krons/work/report.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Report - Multi-step workflow orchestration.
|
|
5
|
+
|
|
6
|
+
A Report orchestrates multiple Forms based on data availability:
|
|
7
|
+
- Schedules forms when their inputs become available
|
|
8
|
+
- Groups forms by branch for sequential execution within branch
|
|
9
|
+
- Propagates outputs between forms
|
|
10
|
+
- Tracks overall workflow completion
|
|
11
|
+
|
|
12
|
+
This is the scheduling layer that enables data-driven DAG execution.
|
|
13
|
+
|
|
14
|
+
The Report pattern supports declarative workflow definition:
|
|
15
|
+
- Fields as class attributes (typed outputs)
|
|
16
|
+
- form_assignments DSL with branch/resource hints
|
|
17
|
+
- Implicit dependencies from data flow
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pydantic import Field
|
|
26
|
+
|
|
27
|
+
from krons.core import Element, Pile
|
|
28
|
+
|
|
29
|
+
from .form import Form, parse_assignment
|
|
30
|
+
|
|
31
|
+
__all__ = ("Report",)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Report(Element):
|
|
35
|
+
"""Workflow orchestrator - schedules forms based on field availability.
|
|
36
|
+
|
|
37
|
+
A Report manages a collection of Forms, executing them as their
|
|
38
|
+
inputs become available. Forms are grouped by branch - forms on the
|
|
39
|
+
same branch execute sequentially, different branches can run in parallel.
|
|
40
|
+
|
|
41
|
+
Example (simple):
|
|
42
|
+
report = Report(
|
|
43
|
+
assignment="context -> final_score",
|
|
44
|
+
form_assignments=[
|
|
45
|
+
"context -> analysis",
|
|
46
|
+
"analysis -> score",
|
|
47
|
+
"score -> final_score",
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
report.initialize(context="some input")
|
|
51
|
+
|
|
52
|
+
while not report.is_complete():
|
|
53
|
+
for form in report.next_forms():
|
|
54
|
+
await form.execute(ctx)
|
|
55
|
+
report.complete_form(form)
|
|
56
|
+
|
|
57
|
+
Example (with branches and resources):
|
|
58
|
+
class HiringBriefReport(Report):
|
|
59
|
+
role_classification: RoleClassification | None = None
|
|
60
|
+
strategic_context: StrategicContext | None = None
|
|
61
|
+
|
|
62
|
+
assignment: str = "job_input -> executive_summary"
|
|
63
|
+
|
|
64
|
+
form_assignments: list[str] = [
|
|
65
|
+
"classifier: job_input -> role_classification | api:fast",
|
|
66
|
+
"strategist: job_input, role_classification -> strategic_context | api:synthesis",
|
|
67
|
+
"writer: strategic_context -> executive_summary | api:reasoning",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
assignment: Overall workflow 'inputs -> final_outputs'
|
|
72
|
+
form_assignments: List of form assignments with optional branch/resource
|
|
73
|
+
input_fields: Workflow input fields
|
|
74
|
+
output_fields: Workflow output fields
|
|
75
|
+
forms: All forms in workflow
|
|
76
|
+
completed_forms: Forms that have finished
|
|
77
|
+
available_data: Current state of all field values
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
assignment: str = Field(
|
|
81
|
+
default="",
|
|
82
|
+
description="Overall workflow: 'inputs -> final_outputs'",
|
|
83
|
+
)
|
|
84
|
+
form_assignments: list[str] = Field(
|
|
85
|
+
default_factory=list,
|
|
86
|
+
description="List of form assignments: ['branch: a -> b | resource', ...]",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
input_fields: list[str] = Field(default_factory=list)
|
|
90
|
+
output_fields: list[str] = Field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
forms: Pile[Form] = Field(
|
|
93
|
+
default_factory=lambda: Pile(item_type=Form),
|
|
94
|
+
description="All forms in the workflow",
|
|
95
|
+
)
|
|
96
|
+
completed_forms: Pile[Form] = Field(
|
|
97
|
+
default_factory=lambda: Pile(item_type=Form),
|
|
98
|
+
description="Completed forms",
|
|
99
|
+
)
|
|
100
|
+
available_data: dict[str, Any] = Field(
|
|
101
|
+
default_factory=dict,
|
|
102
|
+
description="Current state of all field values",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Branch tracking: branch_name -> list of form IDs in order
|
|
106
|
+
_branch_forms: dict[str, list[Form]] = {}
|
|
107
|
+
# Track last completed form per branch for sequential execution
|
|
108
|
+
_branch_progress: dict[str, int] = {}
|
|
109
|
+
|
|
110
|
+
def model_post_init(self, _: Any) -> None:
|
|
111
|
+
"""Parse assignment and create forms."""
|
|
112
|
+
self._branch_forms = defaultdict(list)
|
|
113
|
+
self._branch_progress = defaultdict(int)
|
|
114
|
+
|
|
115
|
+
if not self.assignment:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Parse overall assignment
|
|
119
|
+
self.input_fields, self.output_fields = parse_assignment(self.assignment)
|
|
120
|
+
|
|
121
|
+
# Create forms from form_assignments
|
|
122
|
+
for fa in self.form_assignments:
|
|
123
|
+
form = Form(assignment=fa)
|
|
124
|
+
self.forms.include(form)
|
|
125
|
+
|
|
126
|
+
# Track by branch
|
|
127
|
+
branch = form.branch or "_default"
|
|
128
|
+
self._branch_forms[branch].append(form)
|
|
129
|
+
|
|
130
|
+
def initialize(self, **inputs: Any) -> None:
|
|
131
|
+
"""Provide initial input data.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
**inputs: Initial field values
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If required input is missing
|
|
138
|
+
"""
|
|
139
|
+
for field in self.input_fields:
|
|
140
|
+
if field not in inputs:
|
|
141
|
+
raise ValueError(f"Missing required input: '{field}'")
|
|
142
|
+
self.available_data[field] = inputs[field]
|
|
143
|
+
|
|
144
|
+
def next_forms(self) -> list[Form]:
|
|
145
|
+
"""Get forms that are ready to execute.
|
|
146
|
+
|
|
147
|
+
Forms with explicit branches execute sequentially within their branch.
|
|
148
|
+
Forms without branches (None) execute in parallel based on data availability.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of forms with all inputs available and not yet filled
|
|
152
|
+
"""
|
|
153
|
+
ready = []
|
|
154
|
+
|
|
155
|
+
for branch, forms in self._branch_forms.items():
|
|
156
|
+
if branch == "_default":
|
|
157
|
+
# No explicit branch - parallel execution based on data
|
|
158
|
+
for form in forms:
|
|
159
|
+
if form.filled:
|
|
160
|
+
continue
|
|
161
|
+
form.available_data = self.available_data.copy()
|
|
162
|
+
if form.is_workable():
|
|
163
|
+
ready.append(form)
|
|
164
|
+
else:
|
|
165
|
+
# Explicit branch - sequential execution
|
|
166
|
+
progress = self._branch_progress[branch]
|
|
167
|
+
|
|
168
|
+
# Only consider the next form in this branch
|
|
169
|
+
if progress < len(forms):
|
|
170
|
+
form = forms[progress]
|
|
171
|
+
if form.filled:
|
|
172
|
+
# Already done, advance progress
|
|
173
|
+
self._branch_progress[branch] += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
form.available_data = self.available_data.copy()
|
|
177
|
+
if form.is_workable():
|
|
178
|
+
ready.append(form)
|
|
179
|
+
|
|
180
|
+
return ready
|
|
181
|
+
|
|
182
|
+
def complete_form(self, form: Form) -> None:
|
|
183
|
+
"""Mark a form as completed and update available data.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
form: The completed form
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If form is not filled
|
|
190
|
+
"""
|
|
191
|
+
if not form.filled:
|
|
192
|
+
raise ValueError("Form is not filled")
|
|
193
|
+
|
|
194
|
+
self.completed_forms.include(form)
|
|
195
|
+
|
|
196
|
+
# Advance branch progress
|
|
197
|
+
branch = form.branch or "_default"
|
|
198
|
+
if branch in self._branch_forms:
|
|
199
|
+
forms = self._branch_forms[branch]
|
|
200
|
+
progress = self._branch_progress[branch]
|
|
201
|
+
if progress < len(forms) and forms[progress].id == form.id:
|
|
202
|
+
self._branch_progress[branch] += 1
|
|
203
|
+
|
|
204
|
+
# Update available data with form outputs
|
|
205
|
+
output_data = form.get_output_data()
|
|
206
|
+
self.available_data.update(output_data)
|
|
207
|
+
|
|
208
|
+
def is_complete(self) -> bool:
|
|
209
|
+
"""Check if all output fields are available.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if workflow is complete
|
|
213
|
+
"""
|
|
214
|
+
return all(field in self.available_data for field in self.output_fields)
|
|
215
|
+
|
|
216
|
+
def get_deliverable(self) -> dict[str, Any]:
|
|
217
|
+
"""Get final output values.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Dict of output field values
|
|
221
|
+
"""
|
|
222
|
+
return {f: self.available_data.get(f) for f in self.output_fields}
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def progress(self) -> tuple[int, int]:
|
|
226
|
+
"""Get progress as (completed, total).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (completed forms, total forms)
|
|
230
|
+
"""
|
|
231
|
+
return len(self.completed_forms), len(self.forms)
|
|
232
|
+
|
|
233
|
+
def get_forms_by_branch(self, branch: str) -> list[Form]:
|
|
234
|
+
"""Get all forms for a specific branch.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
branch: Branch name
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of forms on that branch (in order)
|
|
241
|
+
"""
|
|
242
|
+
return list(self._branch_forms.get(branch, []))
|
|
243
|
+
|
|
244
|
+
def get_forms_by_resource(self, resource: str) -> list[Form]:
|
|
245
|
+
"""Get all forms requiring a specific resource.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
resource: Resource hint (e.g., 'api:fast')
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of forms with that resource hint
|
|
252
|
+
"""
|
|
253
|
+
return [f for f in self.forms if f.resource == resource]
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def branches(self) -> list[str]:
|
|
257
|
+
"""Get all branch names in this report."""
|
|
258
|
+
return list(self._branch_forms.keys())
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def resources(self) -> set[str]:
|
|
262
|
+
"""Get all resource hints used in this report."""
|
|
263
|
+
return {f.resource for f in self.forms if f.resource}
|
|
264
|
+
|
|
265
|
+
def __repr__(self) -> str:
|
|
266
|
+
completed, total = self.progress
|
|
267
|
+
branches = len(self._branch_forms)
|
|
268
|
+
return f"Report('{self.assignment}', {completed}/{total} forms, {branches} branches)"
|