krons 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -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/{specs → work}/phrase.py +130 -13
- krons/{enforcement → work}/policy.py +3 -3
- 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/{enforcement → work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
- krons-0.2.0.dist-info/RECORD +154 -0
- krons/enforcement/__init__.py +0 -57
- krons/operations/registry.py +0 -92
- krons/services/__init__.py +0 -81
- 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.0.dist-info}/WHEEL +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.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
|