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
@@ -0,0 +1,100 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Act operation: execute tool calls from LLM action requests.
5
+
6
+ Handler signature: act(params, ctx) → list[ActionResponse]
7
+
8
+ Supports sequential and concurrent execution strategies with
9
+ rate-limiting via alcall (delay, throttle, max_concurrent).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import partial
15
+ from typing import TYPE_CHECKING, Literal
16
+
17
+ from krons.agent.message.action import ActionRequest, ActionResponse
18
+ from krons.core.types import Params
19
+ from krons.session.constraints import resource_must_be_accessible, resource_must_exist
20
+ from krons.utils import alcall
21
+
22
+ if TYPE_CHECKING:
23
+ from krons.resource.backend import Calling
24
+ from krons.session import Branch, Message, Session
25
+ from krons.work.operations import RequestContext
26
+
27
+
28
+ class ActParams(Params):
29
+ """Parameters for tool execution.
30
+
31
+ Attributes:
32
+ action_requests: Messages with ActionRequest content to execute.
33
+ delay_before_start: Initial delay before first execution (seconds).
34
+ throttle_period: Delay between starting tasks (seconds).
35
+ max_concurrent: Concurrency limit (None = unlimited).
36
+ strategy: "concurrent" (default) or "sequential".
37
+ """
38
+
39
+ action_requests: list[Message]
40
+ delay_before_start: float = 0
41
+ throttle_period: float | None = None
42
+ max_concurrent: int | None = None
43
+ strategy: Literal["sequential", "concurrent"] = "concurrent"
44
+
45
+
46
+ async def act(params: ActParams, ctx: RequestContext) -> list[ActionResponse]:
47
+ """Execute tool calls from action_requests."""
48
+ session = await ctx.get_session()
49
+ return await _act(session=session, branch=ctx.branch, **params.to_dict())
50
+
51
+
52
+ async def _act(
53
+ action_requests: list[Message],
54
+ session: Session,
55
+ branch: Branch,
56
+ delay_before_start: float = 0,
57
+ throttle_period: float | None = None,
58
+ max_concurrent: int | None = None,
59
+ strategy: Literal["sequential", "concurrent"] = "concurrent",
60
+ ) -> list[ActionResponse]:
61
+ """Execute action requests against session-registered tools.
62
+
63
+ Validates resource existence and branch access before execution.
64
+ Returns ActionResponse per request (with result or error).
65
+ """
66
+ if not action_requests:
67
+ return []
68
+
69
+ # Validate all resources exist and are accessible before execution
70
+ for req in action_requests:
71
+ content: ActionRequest = req.content
72
+ resource_must_exist(session, content.function)
73
+ resource_must_be_accessible(branch, content.function)
74
+
75
+ async def _execute_one(req_msg: Message) -> ActionResponse:
76
+ """Execute a single action request and normalize result."""
77
+ action_request: ActionRequest = req_msg.content
78
+ calling: Calling = await session.request(
79
+ action_request.function, branch=branch, **action_request.arguments
80
+ )
81
+ try:
82
+ calling.assert_is_normalized()
83
+ except Exception as e:
84
+ return ActionResponse(
85
+ request_id=str(req_msg.id), error=f"ExecutionError: {e}"
86
+ )
87
+ return ActionResponse(request_id=str(req_msg.id), result=calling.response.data)
88
+
89
+ if strategy == "sequential":
90
+ results: list[ActionResponse] = []
91
+ for req_msg in action_requests:
92
+ results.append(await _execute_one(req_msg))
93
+ return results
94
+
95
+ return await partial(
96
+ alcall,
97
+ delay_before_start=delay_before_start,
98
+ throttle_period=throttle_period,
99
+ max_concurrent=max_concurrent,
100
+ )(action_requests, _execute_one)
@@ -0,0 +1,145 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Generate operation: stateless LLM call with message preparation.
5
+
6
+ Handler signature: generate(params, ctx) → Calling | text | raw | Message
7
+ Lowest-level operation — no message persistence, no validation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any, Literal
14
+ from uuid import UUID
15
+
16
+ from pydantic import BaseModel, JsonValue
17
+
18
+ from krons.agent.message import Instruction, prepare_messages_for_chat
19
+ from krons.core.types import ID, MaybeUnset, ModelConfig, Params, Unset
20
+ from krons.errors import ConfigurationError
21
+ from krons.session import Message, resource_must_be_accessible
22
+
23
+ from ..message.common import CustomRenderer
24
+ from .utils import ReturnAs, handle_return
25
+
26
+ if TYPE_CHECKING:
27
+ from krons.resource import iModel
28
+ from krons.session import Branch, Session
29
+ from krons.work.operations import RequestContext
30
+
31
+ __all__ = ("GenerateParams", "generate", "handle_return")
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class GenerateParams(Params):
36
+ """Parameters for generate operation.
37
+
38
+ Provide either `instruction` (pre-built Message/Instruction) or
39
+ `primary` (string) to auto-build an Instruction via Instruction.create().
40
+
41
+ Attributes:
42
+ instruction: Pre-built Instruction or Message (takes priority).
43
+ primary: Instruction text (used when instruction is Unset).
44
+ context: Additional context merged into instruction.
45
+ imodel: Model name or iModel instance (Unset = session default).
46
+ return_as: How to unwrap the Calling result.
47
+ imodel_kwargs: Extra kwargs forwarded to imodel.invoke().
48
+ """
49
+
50
+ _config = ModelConfig(
51
+ sentinel_additions=frozenset({"none", "empty", "dataclass", "pydantic"})
52
+ )
53
+
54
+ instruction: MaybeUnset[Instruction | Message] = Unset
55
+ primary: MaybeUnset[str] = Unset
56
+ context: MaybeUnset[JsonValue] = Unset
57
+ imodel: MaybeUnset[iModel | str] = Unset
58
+ images: MaybeUnset[list[str]] = Unset
59
+ image_detail: MaybeUnset[Literal["low", "high", "auto"]] = Unset
60
+ tool_schemas: MaybeUnset[list[str]] = Unset
61
+ request_model: MaybeUnset[type[BaseModel]] = Unset
62
+ structure_format: Literal["json", "custom"] = "json"
63
+ custom_renderer: MaybeUnset[CustomRenderer] = Unset
64
+ return_as: ReturnAs = ReturnAs.CALLING
65
+ imodel_kwargs: dict[str, Any] = field(default_factory=dict)
66
+
67
+ @property
68
+ def instruction_message(self) -> Message:
69
+ """Resolve to a Message, building from primary if needed."""
70
+ if not self.is_sentinel_field("instruction"):
71
+ if isinstance(self.instruction, Message):
72
+ return self.instruction
73
+ if isinstance(self.instruction, Instruction):
74
+ return Message(content=self.instruction)
75
+
76
+ content = Instruction.create(
77
+ primary=self.primary,
78
+ context=self.context,
79
+ images=self.images,
80
+ image_detail=self.image_detail,
81
+ tool_schemas=self.tool_schemas,
82
+ request_model=self.request_model,
83
+ structure_format=self.structure_format,
84
+ custom_renderer=self.custom_renderer,
85
+ )
86
+ return Message(content=content)
87
+
88
+
89
+ async def generate(params: GenerateParams, ctx: RequestContext) -> Any:
90
+ """Generate operation handler: resolve context and delegate to _generate."""
91
+ session = await ctx.get_session()
92
+ imodel = params.imodel if not params.is_sentinel_field("imodel") else None
93
+
94
+ # Propagate verbose from ctx to imodel_kwargs for streaming pretty-print
95
+ imodel_kwargs = dict(params.imodel_kwargs)
96
+ if ctx.metadata.get("_verbose"):
97
+ imodel_kwargs.setdefault("verbose", True)
98
+
99
+ return await _generate(
100
+ session=session,
101
+ branch=ctx.branch,
102
+ instruction=params.instruction_message,
103
+ imodel=imodel,
104
+ return_as=params.return_as,
105
+ **imodel_kwargs,
106
+ )
107
+
108
+
109
+ async def _generate(
110
+ session: Session,
111
+ branch: Branch | str,
112
+ instruction: Message | Instruction | ID[Message],
113
+ imodel: iModel | str | None = None,
114
+ return_as: ReturnAs = ReturnAs.CALLING,
115
+ **imodel_kwargs: Any,
116
+ ) -> Any:
117
+ """Core generate: resolve model/branch/instruction → invoke → handle_return.
118
+
119
+ Args:
120
+ instruction: Message, Instruction, or message UUID to look up.
121
+ imodel: Model name (resolved from session.resources) or iModel instance.
122
+ return_as: Controls output unwrapping (see ReturnAs).
123
+ **imodel_kwargs: Forwarded to imodel.invoke().
124
+ """
125
+ if imodel is None:
126
+ imodel = session.default_gen_model
127
+ elif isinstance(imodel, str):
128
+ imodel = session.resources.get(imodel, None)
129
+ # else: already an iModel instance
130
+ if imodel is None:
131
+ raise ConfigurationError(
132
+ "Provided imodel could not be resolved, or no default model is set."
133
+ )
134
+
135
+ branch = session.get_branch(branch)
136
+ resource_must_be_accessible(branch, imodel.name)
137
+
138
+ if isinstance(instruction, UUID):
139
+ instruction = session.messages[instruction]
140
+ elif isinstance(instruction, Instruction):
141
+ instruction = Message(content=instruction)
142
+
143
+ prepared_msgs = prepare_messages_for_chat(session.messages, branch, instruction)
144
+ calling = await imodel.invoke(messages=prepared_msgs, **imodel_kwargs)
145
+ return handle_return(calling, return_as)
@@ -0,0 +1,89 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """LLM-assisted reparse: use a model to reformat malformed text into valid JSON.
5
+
6
+ Called as a fallback when direct parse (regex/fuzzy) fails.
7
+ Constructs an Instruction asking the model to extract structured data,
8
+ then fuzzy-validates the result against target keys.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any, Literal
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from krons.agent.message import Instruction
18
+ from krons.core.types import MaybeUnset, Unset, is_sentinel
19
+ from krons.utils.fuzzy import HandleUnmatched, fuzzy_validate_mapping
20
+
21
+ from ..message.common import CustomParser, CustomRenderer
22
+ from .utils import ReturnAs
23
+
24
+ if TYPE_CHECKING:
25
+ from krons.resource import iModel
26
+ from krons.session import Branch, Session
27
+
28
+ __all__ = ("_llm_reparse",)
29
+
30
+ PARSE_PROMPT = (
31
+ "Reformat text into specified model or structure, "
32
+ "using the provided schema format as a guide"
33
+ )
34
+
35
+
36
+ async def _llm_reparse(
37
+ session: Session,
38
+ branch: Branch,
39
+ text: str,
40
+ imodel: iModel | str,
41
+ tool_schemas: MaybeUnset[list[str]] = Unset,
42
+ request_model: MaybeUnset[type[BaseModel]] = Unset,
43
+ structure_format: MaybeUnset[Literal["json", "custom"]] = Unset,
44
+ custom_renderer: MaybeUnset[CustomRenderer] = Unset,
45
+ custom_parser: CustomParser | None = None,
46
+ fill_mapping: dict[str, Any] | None = None,
47
+ **imodel_kwargs: Any,
48
+ ) -> dict[str, Any]:
49
+ """Ask LLM to reformat text into structured JSON.
50
+
51
+ Builds an Instruction with the text as context and the target
52
+ schema from request_model, then generates and parses the result.
53
+
54
+ Returns:
55
+ Dict mapping target keys to extracted values.
56
+ """
57
+ instruction = Instruction.create(
58
+ primary=PARSE_PROMPT,
59
+ context=[{"text_to_format": text}],
60
+ request_model=request_model,
61
+ tool_schemas=tool_schemas,
62
+ structure_format=structure_format,
63
+ custom_renderer=custom_renderer,
64
+ )
65
+
66
+ from .generate import _generate
67
+
68
+ res = await _generate(
69
+ session=session,
70
+ branch=branch,
71
+ instruction=instruction,
72
+ imodel=imodel,
73
+ return_as=ReturnAs.TEXT,
74
+ **imodel_kwargs,
75
+ )
76
+
77
+ if is_sentinel(request_model, {"none", "empty"}):
78
+ raise ValueError("request_model is required for LLM reparse")
79
+ target_keys = list(request_model.model_fields.keys())
80
+
81
+ if custom_parser is not None:
82
+ return custom_parser(res, target_keys)
83
+
84
+ return fuzzy_validate_mapping(
85
+ res,
86
+ target_keys,
87
+ handle_unmatched=HandleUnmatched.FORCE,
88
+ fill_mapping=fill_mapping,
89
+ )
@@ -0,0 +1,247 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Operate: top-level agent operation chain.
5
+
6
+ Handler signature: operate(params, ctx) -> validated model instance
7
+
8
+ Full pipeline:
9
+ 1. Compose request structure from operable (inject action spec if needed)
10
+ 2. Structure: generate -> parse -> validate (produces typed model)
11
+ 3. Act: extract and execute action_requests, persist messages
12
+ 4. Compose response structure, merge action_results, validate
13
+
14
+ Action spec injection:
15
+ If invoke_actions=True AND tool_schemas are present AND the branch
16
+ has "action" in its capabilities, the action_requests spec is injected
17
+ into the operable before composing the request structure. This lets
18
+ the LLM produce structured tool calls alongside regular output fields.
19
+
20
+ Unlike lionagi's operate, krons does NOT allow runtime spec injection
21
+ for arbitrary fields. Only the action spec is injected automatically
22
+ based on explicit capability declarations.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from typing import TYPE_CHECKING, Any, Literal
29
+
30
+ from krons.core.types import MaybeUnset, ModelConfig, Params, Unset
31
+ from krons.session import Message
32
+
33
+ from .act import ActParams, act
34
+ from .generate import GenerateParams
35
+ from .specs import Action, ActionResult, get_action_result_spec, get_action_spec
36
+ from .structure import StructureParams, structure
37
+
38
+ if TYPE_CHECKING:
39
+ from krons.agent.message.common import CustomParser
40
+ from krons.core.specs import Operable
41
+ from krons.resource import iModel
42
+ from krons.work.operations import RequestContext
43
+ from krons.work.rules.validator import Validator
44
+
45
+ __all__ = ("OperateParams", "operate")
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class OperateParams(Params):
50
+ """Parameters for operate (structure + act pipeline).
51
+
52
+ Flat parameter set — no nested StructureParams. The operate handler
53
+ builds StructureParams internally after runtime composition.
54
+
55
+ Attributes:
56
+ operable: Spec definition (required). Used to compose structures.
57
+ validator: Rule-based validator (required).
58
+ generate_params: LLM generation config.
59
+ request_model: Base model type for structured output.
60
+ If None, composed entirely from operable specs.
61
+ capabilities: Field subset for runtime composition.
62
+ invoke_actions: Enable action spec injection and execution.
63
+ action_strategy: "concurrent" (default) or "sequential".
64
+ max_concurrent: Concurrency limit for tool execution.
65
+ throttle_period: Delay between starting tool calls (seconds).
66
+ persist: Persist assistant/action messages to branch.
67
+ """
68
+
69
+ _config = ModelConfig(sentinel_additions=frozenset({"none", "empty"}))
70
+
71
+ # Required
72
+ operable: Operable
73
+ validator: Validator
74
+ generate_params: GenerateParams
75
+
76
+ # Structure composition
77
+ capabilities: set[str] | None = None
78
+ persist: bool = True
79
+
80
+ # Action stage
81
+ invoke_actions: bool = False
82
+ action_strategy: Literal["sequential", "concurrent"] = "concurrent"
83
+ max_concurrent: int | None = None
84
+ throttle_period: float | None = None
85
+
86
+ # Validation
87
+ auto_fix: bool = True
88
+ strict: bool = True
89
+
90
+ # Parse overrides
91
+ parse_imodel: MaybeUnset[iModel | str] = Unset
92
+ parse_imodel_kwargs: dict[str, Any] = field(default_factory=dict)
93
+ custom_parser: CustomParser | None = None
94
+ similarity_threshold: float = 0.85
95
+ max_retries: int = 3
96
+ fill_mapping: dict[str, Any] | None = None
97
+ fill_value: Any = Unset
98
+
99
+
100
+ async def operate(params: OperateParams, ctx: RequestContext) -> Any:
101
+ """Operate handler: compose -> structure -> act -> merge.
102
+
103
+ Returns a validated model instance. If actions were invoked,
104
+ the response structure includes action_results.
105
+ """
106
+ session = await ctx.get_session()
107
+ branch = await ctx.get_branch()
108
+
109
+ operable = params.operable
110
+ gen_params = params.generate_params
111
+
112
+ # Determine if action spec should be injected
113
+ has_tools = not gen_params.is_sentinel_field("tool_schemas")
114
+ branch_caps = getattr(branch, "capabilities", set())
115
+ inject_actions = params.invoke_actions and has_tools and "action" in branch_caps
116
+
117
+ # --- Stage 1: Compose request structure ---
118
+ if inject_actions:
119
+ request_operable = operable.extend([get_action_spec()])
120
+ else:
121
+ request_operable = operable
122
+
123
+ request_structure = request_operable.compose_structure()
124
+
125
+ # Update generate params with the composed request model
126
+ use_gen_params = gen_params.with_updates(
127
+ copy_containers="deep", request_model=request_structure
128
+ )
129
+
130
+ # --- Stage 2: Structure (generate -> parse -> validate) ---
131
+ structure_params = StructureParams(
132
+ generate_params=use_gen_params,
133
+ validator=params.validator,
134
+ operable=request_operable,
135
+ structure=request_structure,
136
+ persist=params.persist,
137
+ capabilities=params.capabilities,
138
+ auto_fix=params.auto_fix,
139
+ strict=params.strict,
140
+ parse_imodel=params.parse_imodel,
141
+ parse_imodel_kwargs=params.parse_imodel_kwargs,
142
+ custom_parser=params.custom_parser,
143
+ similarity_threshold=params.similarity_threshold,
144
+ max_retries=params.max_retries,
145
+ fill_mapping=params.fill_mapping,
146
+ fill_value=params.fill_value,
147
+ )
148
+ structured = await structure(structure_params, ctx)
149
+
150
+ # --- Stage 3: Extract and execute actions ---
151
+ if not inject_actions:
152
+ return structured
153
+
154
+ act_requests = getattr(structured, "action_requests", None)
155
+ if not act_requests:
156
+ return structured
157
+
158
+ # Convert Action models to ActionRequest messages, persist to branch
159
+ action_messages = _actions_to_messages(act_requests)
160
+ if not action_messages:
161
+ return structured
162
+
163
+ for msg in action_messages:
164
+ session.add_message(msg, branches=branch)
165
+
166
+ act_params = ActParams(
167
+ action_requests=action_messages,
168
+ strategy=params.action_strategy,
169
+ max_concurrent=params.max_concurrent,
170
+ throttle_period=params.throttle_period,
171
+ )
172
+ action_responses = await act(act_params, ctx)
173
+
174
+ # Persist action response messages to branch
175
+ for resp in action_responses:
176
+ resp_msg = Message(content=resp)
177
+ session.add_message(resp_msg, branches=branch)
178
+
179
+ # Build ActionResult models from ActionResponse messages
180
+ action_results = _responses_to_results(action_responses, action_messages)
181
+
182
+ # --- Stage 4: Compose response structure, merge, return ---
183
+ # No re-validation needed: structured output was validated in stage 2,
184
+ # action_results are execution artifacts (not LLM output).
185
+ response_operable = request_operable.extend([get_action_result_spec()])
186
+ response_structure = response_operable.compose_structure()
187
+
188
+ data = request_operable.dump_instance(structured)
189
+ data["action_results"] = action_results
190
+
191
+ return response_structure(**data)
192
+
193
+
194
+ def _actions_to_messages(act_requests: list) -> list[Message]:
195
+ """Convert Action models from structured output to ActionRequest Messages."""
196
+ from krons.agent.message.action import ActionRequest
197
+
198
+ messages: list[Message] = []
199
+ for req in act_requests:
200
+ if isinstance(req, Action):
201
+ content = ActionRequest.create(
202
+ function=req.function, arguments=req.arguments
203
+ )
204
+ messages.append(Message(content=content))
205
+ elif isinstance(req, dict):
206
+ content = ActionRequest.create(
207
+ function=req.get("function", ""),
208
+ arguments=req.get("arguments", {}),
209
+ )
210
+ messages.append(Message(content=content))
211
+ return messages
212
+
213
+
214
+ def _responses_to_results(
215
+ action_responses: list,
216
+ action_messages: list[Message],
217
+ ) -> list[ActionResult]:
218
+ """Convert ActionResponse messages to ActionResult spec models.
219
+
220
+ Maps request_id back to function name via action_messages.
221
+ """
222
+ from krons.agent.message.action import ActionResponse
223
+
224
+ # Build request_id → function lookup from action messages
225
+ id_to_func: dict[str, str] = {}
226
+ for msg in action_messages:
227
+ content = msg.content
228
+ if hasattr(content, "function"):
229
+ id_to_func[str(msg.id)] = content.function
230
+
231
+ results: list[ActionResult] = []
232
+ for resp in action_responses:
233
+ if isinstance(resp, ActionResponse):
234
+ func = id_to_func.get(
235
+ resp.request_id if not resp._is_sentinel(resp.request_id) else "",
236
+ "",
237
+ )
238
+ results.append(
239
+ ActionResult(
240
+ function=func,
241
+ result=resp.result if resp.success else None,
242
+ error=resp.error if not resp.success else None,
243
+ )
244
+ )
245
+ elif isinstance(resp, dict):
246
+ results.append(ActionResult.model_validate(resp))
247
+ return results