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.
Files changed (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +115 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +242 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/work/report.py +268 -0
  112. krons/work/rules/__init__.py +47 -0
  113. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  114. krons/{enforcement → work/rules}/common/choice.py +9 -3
  115. krons/{enforcement → work/rules}/common/number.py +3 -1
  116. krons/{enforcement → work/rules}/common/string.py +9 -3
  117. krons/{enforcement → work/rules}/rule.py +1 -1
  118. krons/{enforcement → work/rules}/validator.py +20 -5
  119. krons/work/worker.py +266 -0
  120. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
  121. krons-0.2.1.dist-info/RECORD +151 -0
  122. krons/enforcement/__init__.py +0 -57
  123. krons/enforcement/policy.py +0 -80
  124. krons/enforcement/service.py +0 -370
  125. krons/operations/registry.py +0 -92
  126. krons/services/__init__.py +0 -81
  127. krons/specs/phrase.py +0 -405
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
  142. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,243 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Parse operation: extract structured JSON from raw LLM text.
5
+
6
+ Handler signature: parse(params, ctx) → dict[str, Any]
7
+
8
+ Two-stage pipeline:
9
+ 1. _direct_parse: regex/fuzzy extraction (fast, no LLM call)
10
+ 2. _llm_reparse: LLM-assisted fallback (up to max_retries)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from krons.agent.message.common import CustomParser, CustomRenderer, StructureFormat
21
+ from krons.core.types import MaybeUnset, ModelConfig, Params, Unset, is_sentinel
22
+ from krons.errors import ConfigurationError, ExecutionError, KronsError, ValidationError
23
+ from krons.utils.fuzzy import HandleUnmatched, extract_json, fuzzy_validate_mapping
24
+
25
+ if TYPE_CHECKING:
26
+ from krons.resource.imodel import iModel
27
+ from krons.session import Branch, Session
28
+ from krons.work.operations import RequestContext
29
+
30
+ __all__ = ("ParseParams", "parse")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ParseParams(Params):
35
+ """Parameters for parse operation.
36
+
37
+ Attributes:
38
+ text: Raw text to parse (required).
39
+ target_keys: Expected keys for fuzzy matching (or derived from request_model).
40
+ imodel: Model for LLM reparse fallback.
41
+ structure_format: JSON (default) or custom parser.
42
+ max_retries: LLM reparse attempts (1-5, 0 = direct only).
43
+ """
44
+
45
+ _config = ModelConfig(sentinel_additions=frozenset({"none", "empty"}))
46
+
47
+ text: str
48
+ target_keys: MaybeUnset[list[str]] = Unset
49
+ imodel: iModel | str | None = None
50
+ imodel_kwargs: dict[str, Any] = field(default_factory=dict)
51
+ custom_parser: CustomParser | None = None
52
+ custom_renderer: MaybeUnset[CustomRenderer] = Unset
53
+ structure_format: StructureFormat = StructureFormat.JSON
54
+ tool_schemas: MaybeUnset[list[str]] = Unset
55
+ request_model: MaybeUnset[type[BaseModel]] = Unset
56
+ similarity_threshold: float = 0.85
57
+ handle_unmatched: HandleUnmatched = HandleUnmatched.FORCE
58
+ max_retries: int = 3
59
+ fill_mapping: dict[str, Any] | None = None
60
+ fill_value: Any = Unset
61
+
62
+
63
+ async def parse(params: ParseParams, ctx: RequestContext) -> dict[str, Any]:
64
+ """Parse operation handler: resolve target_keys and delegate to _parse."""
65
+ target_keys = params.target_keys
66
+
67
+ if params.is_sentinel_field("target_keys"):
68
+ if params.is_sentinel_field("request_model"):
69
+ raise ValidationError(
70
+ "Either 'target_keys' or 'request_model' must be provided for parse"
71
+ )
72
+ target_keys = list(params.request_model.model_fields.keys())
73
+
74
+ session = await ctx.get_session()
75
+ data = params.to_dict(exclude={"target_keys", "imodel_kwargs"})
76
+
77
+ return await _parse(
78
+ session=session,
79
+ branch=ctx.branch,
80
+ target_keys=target_keys,
81
+ **data,
82
+ **params.imodel_kwargs,
83
+ )
84
+
85
+
86
+ async def _parse(
87
+ session: Session,
88
+ branch: Branch | str,
89
+ text: str,
90
+ target_keys: list[str],
91
+ structure_format: StructureFormat = StructureFormat.JSON,
92
+ custom_parser: CustomParser | None = None,
93
+ similarity_threshold: float = 0.85,
94
+ handle_unmatched: HandleUnmatched = HandleUnmatched.FORCE,
95
+ fill_mapping: dict[str, Any] | None = None,
96
+ fill_value: Any = Unset,
97
+ max_retries: MaybeUnset[int] = Unset,
98
+ imodel: iModel | str | None = None,
99
+ tool_schemas: MaybeUnset[list[str]] = Unset,
100
+ request_model: MaybeUnset[type[BaseModel]] = Unset,
101
+ custom_renderer: MaybeUnset[CustomRenderer] = Unset,
102
+ **imodel_kwargs: Any,
103
+ ) -> dict[str, Any]:
104
+ """Two-stage parse: try direct extraction, fall back to LLM reparse.
105
+
106
+ Raises:
107
+ ValidationError: Missing required params.
108
+ ExecutionError: All parse attempts failed.
109
+ """
110
+ _sentinel_check = {"none", "empty"}
111
+ if is_sentinel(target_keys, _sentinel_check):
112
+ raise ValidationError("No target_keys provided for parse operation")
113
+ if is_sentinel(text, _sentinel_check):
114
+ raise ValidationError("No text provided for parse operation")
115
+
116
+ # Stage 1: direct parse (no LLM call)
117
+ direct_error: Exception | None = None
118
+ try:
119
+ return _direct_parse(
120
+ text=text,
121
+ target_keys=target_keys,
122
+ structure_format=structure_format,
123
+ custom_parser=custom_parser,
124
+ similarity_threshold=similarity_threshold,
125
+ handle_unmatched=handle_unmatched,
126
+ fill_mapping=fill_mapping,
127
+ fill_value=fill_value,
128
+ )
129
+ except KronsError as e:
130
+ if e.retryable is False:
131
+ raise
132
+ direct_error = e
133
+ except Exception as e:
134
+ direct_error = e
135
+
136
+ # Stage 2: LLM reparse fallback
137
+ if is_sentinel(max_retries, _sentinel_check) or max_retries < 1:
138
+ raise ExecutionError(
139
+ "Direct parse failed and max_retries not enabled, no reparse attempted",
140
+ retryable=False,
141
+ cause=direct_error,
142
+ )
143
+
144
+ from .llm_reparse import _llm_reparse
145
+
146
+ for _ in range(max_retries):
147
+ try:
148
+ return await _llm_reparse(
149
+ session=session,
150
+ branch=branch,
151
+ text=text,
152
+ imodel=imodel,
153
+ tool_schemas=tool_schemas,
154
+ request_model=request_model,
155
+ structure_format=structure_format,
156
+ custom_renderer=custom_renderer,
157
+ custom_parser=custom_parser,
158
+ **imodel_kwargs,
159
+ )
160
+ except KronsError as e:
161
+ if e.retryable is False:
162
+ raise
163
+
164
+ raise ExecutionError(
165
+ "All parse attempts (direct and LLM reparse) failed",
166
+ retryable=False,
167
+ )
168
+
169
+
170
+ def _direct_parse(
171
+ text: str,
172
+ target_keys: list[str],
173
+ structure_format: StructureFormat = StructureFormat.JSON,
174
+ custom_parser: CustomParser | None = None,
175
+ similarity_threshold: float = 0.85,
176
+ handle_unmatched: HandleUnmatched = HandleUnmatched.FORCE,
177
+ fill_mapping: dict[str, Any] | None = None,
178
+ fill_value: Any = Unset,
179
+ ) -> dict[str, Any]:
180
+ """Extract JSON from text without LLM assistance.
181
+
182
+ Routes to custom_parser or built-in JSON extraction + fuzzy matching.
183
+ """
184
+ _sentinel_check = {"none", "empty"}
185
+ if is_sentinel(target_keys, _sentinel_check):
186
+ raise ValidationError("No target_keys provided for direct_parse operation")
187
+
188
+ match structure_format:
189
+ case StructureFormat.CUSTOM:
190
+ if not callable(custom_parser):
191
+ raise ConfigurationError(
192
+ "structure_format='custom' requires a custom_parser to be provided",
193
+ retryable=False,
194
+ )
195
+ try:
196
+ return custom_parser(text, target_keys)
197
+ except Exception as e:
198
+ raise ExecutionError(
199
+ "Custom parser failed to extract data from text",
200
+ retryable=True,
201
+ cause=e,
202
+ )
203
+
204
+ case StructureFormat.JSON:
205
+ pass
206
+
207
+ case _:
208
+ raise ValidationError(
209
+ f"Unsupported structure_format '{structure_format}' in direct_parse",
210
+ retryable=False,
211
+ )
212
+
213
+ extracted = Unset
214
+ try:
215
+ extracted = extract_json(text, fuzzy_parse=True, return_one_if_single=False)
216
+ except Exception as e:
217
+ raise ExecutionError(
218
+ "Failed to extract JSON from text during parse",
219
+ retryable=True,
220
+ cause=e,
221
+ )
222
+
223
+ if is_sentinel(extracted, _sentinel_check):
224
+ raise ExecutionError(
225
+ "No JSON object could be extracted from text during parse",
226
+ retryable=True,
227
+ )
228
+
229
+ try:
230
+ return fuzzy_validate_mapping(
231
+ extracted[0],
232
+ target_keys,
233
+ similarity_threshold=similarity_threshold,
234
+ handle_unmatched=handle_unmatched,
235
+ fill_mapping=fill_mapping,
236
+ fill_value=fill_value,
237
+ )
238
+ except Exception as e:
239
+ raise ExecutionError(
240
+ "Failed to validate extracted JSON during parse",
241
+ retryable=True,
242
+ cause=e,
243
+ )
@@ -0,0 +1,286 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """ReAct: multi-round reason-act loop built on operate.
5
+
6
+ Handler signature: react(params, ctx) -> final answer instance
7
+ Streaming variant: react_stream(params, ctx) -> AsyncGenerator[round analysis]
8
+
9
+ Each round:
10
+ 1. operate() with ReActAnalysis as request model
11
+ 2. LLM produces reasoning + planned_actions + extension_needed
12
+ 3. If actions present and allowed, operate handles execution
13
+ 4. If extension_needed and rounds remain, loop continues
14
+ 5. Final round: operate() with user's response model for the answer
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import AsyncGenerator
20
+ from dataclasses import dataclass, field
21
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal
22
+
23
+ from pydantic import Field, field_validator
24
+
25
+ from krons.core.types import HashableModel, MaybeUnset, ModelConfig, Params, Unset
26
+
27
+ from .generate import GenerateParams
28
+ from .operate import OperateParams, operate
29
+
30
+ if TYPE_CHECKING:
31
+ from krons.core.specs import Operable
32
+ from krons.resource import iModel
33
+ from krons.work.operations import RequestContext
34
+ from krons.work.rules.validator import Validator
35
+
36
+ __all__ = (
37
+ "Analysis",
38
+ "PlannedAction",
39
+ "ReActAnalysis",
40
+ "ReActParams",
41
+ "react",
42
+ "react_stream",
43
+ )
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # ReAct spec models
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class PlannedAction(HashableModel):
52
+ """Short descriptor for an upcoming tool invocation."""
53
+
54
+ action_type: str | None = Field(
55
+ default=None,
56
+ description="Name or type of tool/action to invoke.",
57
+ )
58
+ description: str | None = Field(
59
+ default=None,
60
+ description="Concise summary of what the action entails and why.",
61
+ )
62
+
63
+
64
+ class ReActAnalysis(HashableModel):
65
+ """Structured reasoning output for each ReAct round.
66
+
67
+ The LLM fills this to express its chain-of-thought, plan actions,
68
+ and signal whether more rounds are needed.
69
+ """
70
+
71
+ FIRST_ROUND_PROMPT: ClassVar[str] = (
72
+ "You can perform multiple reason-action steps for accuracy. "
73
+ "If you are not ready to finalize, set extension_needed to True. "
74
+ "Set extension_needed to True if the overall goal is not yet achieved. "
75
+ "Do not set it to False if you are just providing an interim answer. "
76
+ "You have up to {max_rounds} rounds. Strategize accordingly."
77
+ )
78
+ CONTINUE_PROMPT: ClassVar[str] = (
79
+ "Another round is available. You may do multiple actions if needed. "
80
+ "You have up to {remaining} rounds remaining. Continue."
81
+ )
82
+ ANSWER_PROMPT: ClassVar[str] = (
83
+ "Given your reasoning and actions, provide the final answer "
84
+ "to the user's request:\n\n{instruction}"
85
+ )
86
+
87
+ analysis: str = Field(
88
+ ...,
89
+ description=(
90
+ "Free-form reasoning or chain-of-thought summary. "
91
+ "Use for planning, reflection, and progress tracking."
92
+ ),
93
+ )
94
+ planned_actions: list[PlannedAction] = Field(
95
+ default_factory=list,
96
+ description="Tool calls or operations to perform this round.",
97
+ )
98
+ extension_needed: bool = Field(
99
+ False,
100
+ description="True if more rounds are needed. False triggers final answer.",
101
+ )
102
+ milestone: str | None = Field(
103
+ None,
104
+ description="Sub-goal or checkpoint to reach before finalizing.",
105
+ )
106
+ action_strategy: Literal["sequential", "concurrent"] = Field(
107
+ "concurrent",
108
+ description="How to execute planned actions: sequential or concurrent.",
109
+ )
110
+
111
+
112
+ class Analysis(HashableModel):
113
+ """Final answer model (default response_model for react)."""
114
+
115
+ answer: str | None = None
116
+
117
+ @field_validator("answer", mode="before")
118
+ @classmethod
119
+ def _validate_answer(cls, value: Any) -> str | None:
120
+ if not value:
121
+ return None
122
+ if isinstance(value, str) and not value.strip():
123
+ return None
124
+ if not isinstance(value, str):
125
+ raise ValueError("Answer must be a non-empty string.")
126
+ return value.strip()
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # ReAct params
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class ReActParams(Params):
136
+ """Parameters for ReAct loop.
137
+
138
+ Attributes:
139
+ instruction: The user's task/question.
140
+ operable: Spec definition for structured output composition.
141
+ validator: Rule-based validator.
142
+ generate_params: LLM generation config.
143
+ max_rounds: Maximum reason-act rounds before forcing final answer.
144
+ response_model: Model for the final answer (default: Analysis).
145
+ invoke_actions: Enable tool execution in each round.
146
+ action_strategy: Default strategy (overridden by LLM per-round).
147
+ persist: Persist messages to branch.
148
+ """
149
+
150
+ _config = ModelConfig(sentinel_additions=frozenset({"none", "empty"}))
151
+
152
+ # Required
153
+ instruction: str
154
+ operable: Operable
155
+ validator: Validator
156
+ generate_params: GenerateParams
157
+
158
+ # ReAct loop config
159
+ max_rounds: int = 3
160
+ response_model: type | None = None
161
+ invoke_actions: bool = True
162
+ persist: bool = True
163
+
164
+ # Action defaults
165
+ action_strategy: Literal["sequential", "concurrent"] = "concurrent"
166
+ max_concurrent: int | None = None
167
+ throttle_period: float | None = None
168
+
169
+ # Validation
170
+ auto_fix: bool = True
171
+ strict: bool = True
172
+
173
+ # Parse overrides
174
+ parse_imodel: MaybeUnset[iModel | str] = Unset
175
+ parse_imodel_kwargs: dict[str, Any] = field(default_factory=dict)
176
+ similarity_threshold: float = 0.85
177
+ max_retries: int = 3
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Handlers
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ async def react(params: ReActParams, ctx: RequestContext) -> Any:
186
+ """ReAct handler: collects all rounds, returns final answer.
187
+
188
+ The final answer is extracted from the last round's Analysis.answer
189
+ if response_model is not provided (defaults to Analysis).
190
+ """
191
+ result = None
192
+ async for round_result in react_stream(params, ctx):
193
+ result = round_result
194
+
195
+ # Last yield is the final answer
196
+ if result is not None and hasattr(result, "answer"):
197
+ return result.answer
198
+ return result
199
+
200
+
201
+ async def react_stream(
202
+ params: ReActParams, ctx: RequestContext
203
+ ) -> AsyncGenerator[Any, None]:
204
+ """Streaming ReAct: yields each round's structured analysis.
205
+
206
+ Yields:
207
+ Round 1..N: ReActAnalysis instances (reasoning + action results)
208
+ Final: response_model instance (the answer)
209
+ """
210
+ max_rounds = min(params.max_rounds, 100)
211
+
212
+ # --- Round 1: Initial analysis ---
213
+ instruction_with_prompt = (
214
+ params.instruction
215
+ + "\n\n"
216
+ + ReActAnalysis.FIRST_ROUND_PROMPT.format(max_rounds=max_rounds)
217
+ )
218
+
219
+ analysis = await _run_round(params, ctx, instruction_with_prompt, ReActAnalysis)
220
+ yield analysis
221
+
222
+ # --- Extension rounds ---
223
+ remaining = max_rounds - 1
224
+ while remaining > 0 and _needs_extension(analysis):
225
+ prompt = ReActAnalysis.CONTINUE_PROMPT.format(remaining=remaining)
226
+ analysis = await _run_round(params, ctx, prompt, ReActAnalysis)
227
+ yield analysis
228
+ remaining -= 1
229
+
230
+ # --- Final answer ---
231
+ answer_model = params.response_model or Analysis
232
+ answer_prompt = ReActAnalysis.ANSWER_PROMPT.format(instruction=params.instruction)
233
+ final = await _run_round(
234
+ params, ctx, answer_prompt, answer_model, invoke_actions=False
235
+ )
236
+ yield final
237
+
238
+
239
+ async def _run_round(
240
+ params: ReActParams,
241
+ ctx: RequestContext,
242
+ instruction: str,
243
+ request_model: type,
244
+ invoke_actions: bool | None = None,
245
+ ) -> Any:
246
+ """Execute a single ReAct round via operate."""
247
+ from krons.core.specs import Operable
248
+
249
+ # Build operable from the round's request model
250
+ round_operable = Operable.from_structure(request_model)
251
+
252
+ # Override instruction in generate params
253
+ gen_params = params.generate_params.with_updates(
254
+ copy_containers="deep", primary=instruction
255
+ )
256
+
257
+ # Resolve action strategy from previous analysis if available
258
+ should_act = invoke_actions if invoke_actions is not None else params.invoke_actions
259
+
260
+ operate_params = OperateParams(
261
+ operable=round_operable,
262
+ validator=params.validator,
263
+ generate_params=gen_params,
264
+ invoke_actions=should_act,
265
+ action_strategy=params.action_strategy,
266
+ max_concurrent=params.max_concurrent,
267
+ throttle_period=params.throttle_period,
268
+ persist=params.persist,
269
+ auto_fix=params.auto_fix,
270
+ strict=params.strict,
271
+ parse_imodel=params.parse_imodel,
272
+ parse_imodel_kwargs=params.parse_imodel_kwargs,
273
+ similarity_threshold=params.similarity_threshold,
274
+ max_retries=params.max_retries,
275
+ )
276
+
277
+ return await operate(operate_params, ctx)
278
+
279
+
280
+ def _needs_extension(analysis: Any) -> bool:
281
+ """Check if the analysis signals more rounds are needed."""
282
+ if hasattr(analysis, "extension_needed"):
283
+ return bool(analysis.extension_needed)
284
+ if isinstance(analysis, dict):
285
+ return bool(analysis.get("extension_needed", False))
286
+ return False