python-corekit 0.3.0__py3-none-any.whl → 0.4.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.
@@ -0,0 +1,149 @@
1
+ """
2
+ Think-tag streaming helpers for llama.cpp-style ``<think>`` content.
3
+ """
4
+
5
+ import re
6
+ from collections.abc import Generator, Iterator
7
+ from enum import Enum
8
+ from typing import NamedTuple
9
+
10
+ from corekit.llm.events import StreamEvent, TextEvent, ThinkingEvent
11
+
12
+ __all__ = [
13
+ "THINK_PATTERN",
14
+ "ModelStreamingState",
15
+ "ThinkingMarker",
16
+ "ThinkingMarkerPair",
17
+ "strip_thinking",
18
+ ]
19
+
20
+ THINK_PATTERN = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
21
+
22
+
23
+ def strip_thinking(text: str) -> str:
24
+ """
25
+ Remove complete ``<think>…</think>`` blocks from a finished string.
26
+ """
27
+ return THINK_PATTERN.sub("", text).strip()
28
+
29
+
30
+ class ThinkingMarkerPair(NamedTuple):
31
+ start: str
32
+ end: str
33
+
34
+
35
+ class ThinkingMarker(Enum):
36
+ """
37
+ Delimiter pairs for thinking regions in model content.
38
+ """
39
+
40
+ XML_THINK = ThinkingMarkerPair(start="<think>", end="</think>")
41
+
42
+ @property
43
+ def start(self) -> str:
44
+ return self.value.start
45
+
46
+ @property
47
+ def end(self) -> str:
48
+ return self.value.end
49
+
50
+
51
+ class ModelStreamingState:
52
+ """
53
+ Tracks ``<think>…</think>`` (or other marker) parse state across chunks.
54
+ """
55
+
56
+ def __init__(self, marker: ThinkingMarker = ThinkingMarker.XML_THINK) -> None:
57
+ self.marker = marker
58
+ self.inside = False
59
+ self.text_buffer = ""
60
+ self.think_buffer = ""
61
+ self._complete_buffer = ""
62
+ self._pending = "" # held-back prefix of an open/close marker
63
+
64
+ def format(self) -> str:
65
+ return (
66
+ f"ModelStreamingState(text={self.text_buffer!r}, "
67
+ f"think={self.think_buffer!r}, complete={self._complete_buffer!r})"
68
+ )
69
+
70
+ def feed(self, delta: str) -> Iterator[StreamEvent]:
71
+ """
72
+ Parse one content delta; yield text / thinking events and update buffers.
73
+ """
74
+ buf = self._pending + delta
75
+ self._pending = ""
76
+
77
+ while buf:
78
+ if self.inside:
79
+ buf = yield from self._feed_inside(buf)
80
+ else:
81
+ buf = yield from self._feed_outside(buf)
82
+
83
+ def _feed_outside(self, buf: str) -> Generator[StreamEvent, None, str]:
84
+ """
85
+ Emit text until a start marker; return leftover buffer.
86
+ """
87
+ idx = buf.find(self.marker.start)
88
+ if idx != -1:
89
+ before = buf[:idx]
90
+ if before:
91
+ self._append_text(before)
92
+ yield TextEvent(content=before)
93
+ self.inside = True
94
+ return buf[idx + len(self.marker.start) :]
95
+
96
+ partial = self._partial_tag_suffix(buf, self.marker.start)
97
+ if partial:
98
+ emit = buf[: -len(partial)]
99
+ if emit:
100
+ self._append_text(emit)
101
+ yield TextEvent(content=emit)
102
+ self._pending = partial
103
+ else:
104
+ self._append_text(buf)
105
+ yield TextEvent(content=buf)
106
+ return ""
107
+
108
+ def _feed_inside(self, buf: str) -> Generator[StreamEvent, None, str]:
109
+ """
110
+ Emit thinking until an end marker; return leftover buffer.
111
+ """
112
+ idx = buf.find(self.marker.end)
113
+ if idx != -1:
114
+ thought = buf[:idx]
115
+ if thought:
116
+ self._append_think(thought)
117
+ yield ThinkingEvent(content=thought)
118
+ self.inside = False
119
+ return buf[idx + len(self.marker.end) :].lstrip()
120
+
121
+ partial = self._partial_tag_suffix(buf, self.marker.end)
122
+ if partial:
123
+ emit = buf[: -len(partial)]
124
+ if emit:
125
+ self._append_think(emit)
126
+ yield ThinkingEvent(content=emit)
127
+ self._pending = partial
128
+ else:
129
+ self._append_think(buf)
130
+ yield ThinkingEvent(content=buf)
131
+ return ""
132
+
133
+ def _append_text(self, new: str) -> None:
134
+ self.text_buffer += new
135
+ self._complete_buffer += new
136
+
137
+ def _append_think(self, new: str) -> None:
138
+ self.think_buffer += new
139
+ self._complete_buffer += new
140
+
141
+ @staticmethod
142
+ def _partial_tag_suffix(text: str, tag: str) -> str:
143
+ """
144
+ Longest non-empty suffix of ``text`` that is a prefix of ``tag``.
145
+ """
146
+ for i in range(min(len(tag) - 1, len(text)), 0, -1):
147
+ if text.endswith(tag[:i]):
148
+ return text[-i:]
149
+ return ""
@@ -0,0 +1,19 @@
1
+ """
2
+ Tool vocabulary, registry, and detectors.
3
+
4
+ ``ToolLoop`` lives in ``corekit.llm.tools.loop`` and is re-exported from
5
+ ``corekit.llm`` — importing it here would cycle through ``events``.
6
+ """
7
+
8
+ from corekit.llm.tools.base import Tool, ToolCall, ToolResult
9
+ from corekit.llm.tools.detection import MAX_TOOL_ITERATIONS, ToolCallLoopDetector
10
+ from corekit.llm.tools.registry import ToolRegistry
11
+
12
+ __all__ = [
13
+ "MAX_TOOL_ITERATIONS",
14
+ "Tool",
15
+ "ToolCall",
16
+ "ToolCallLoopDetector",
17
+ "ToolRegistry",
18
+ "ToolResult",
19
+ ]
@@ -0,0 +1,118 @@
1
+ """
2
+ Tool call / result vocabulary and the Tool contract.
3
+ """
4
+
5
+ import abc
6
+ import json
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from corekit.llm.enum import Role, ToolKind, WireField
12
+
13
+ __all__ = ["Tool", "ToolCall", "ToolResult"]
14
+
15
+
16
+ class ToolCall(BaseModel):
17
+ """
18
+ A model-requested tool invocation.
19
+ """
20
+
21
+ id: str
22
+ name: str
23
+ arguments: dict[str, Any]
24
+
25
+ def arguments_json(self) -> str:
26
+ """
27
+ Serialize arguments for an OpenAI ``function.arguments`` field.
28
+ """
29
+ return json.dumps(self.arguments, default=str)
30
+
31
+ def signature(self) -> str:
32
+ """
33
+ Stable identity for loop detection (name + sorted args).
34
+ """
35
+ return f"{self.name}:{json.dumps(self.arguments, sort_keys=True, default=str)}"
36
+
37
+
38
+ class ToolResult(BaseModel):
39
+ """
40
+ Outcome of executing a tool call, ready to feed back as a tool message.
41
+ """
42
+
43
+ tool_call_id: str
44
+ name: str
45
+ content: Any # JSON-serializable; rendered back to the model as a string
46
+ is_error: bool = False
47
+
48
+ def to_message_content(self) -> str:
49
+ """
50
+ Serialize content for the OpenAI ``role=tool`` message body.
51
+ """
52
+ if isinstance(self.content, str):
53
+ return self.content
54
+ return json.dumps(self.content, default=str)
55
+
56
+ def to_openai_message(self) -> dict[str, Any]:
57
+ """
58
+ OpenAI ``role=tool`` chat-message dict for the next completion turn.
59
+ """
60
+ return {
61
+ WireField.ROLE: Role.TOOL,
62
+ WireField.TOOL_CALL_ID: self.tool_call_id,
63
+ WireField.CONTENT: self.to_message_content(),
64
+ }
65
+
66
+ def with_call_id(self, tool_call_id: str) -> "ToolResult":
67
+ """
68
+ Return a copy with ``tool_call_id`` set (tools often leave it blank).
69
+ """
70
+ return self.model_copy(update={"tool_call_id": tool_call_id})
71
+
72
+
73
+ class Tool(abc.ABC):
74
+ """
75
+ One callable tool. Subclass, set class attributes, implement ``execute``.
76
+
77
+ The agentic loop always awaits ``aexecute``. Override ``aexecute`` for
78
+ I/O-bound tools; the default implementation runs sync ``execute``.
79
+ """
80
+
81
+ name: str
82
+ description: str
83
+ arguments_model: type[BaseModel]
84
+ kind: ToolKind = ToolKind.DATA
85
+
86
+ @abc.abstractmethod
87
+ def execute(self, arguments: dict[str, Any]) -> ToolResult:
88
+ """
89
+ Run the tool with validated (or raw) arguments.
90
+ """
91
+ raise NotImplementedError
92
+
93
+ async def aexecute(self, arguments: dict[str, Any]) -> ToolResult:
94
+ """
95
+ Async entry point. Default delegates to ``execute``.
96
+ """
97
+ return self.execute(arguments)
98
+
99
+ def parse_arguments(self, arguments: dict[str, Any]) -> BaseModel:
100
+ """
101
+ Validate arguments against ``arguments_model``.
102
+ """
103
+ return self.arguments_model.model_validate(arguments)
104
+
105
+ def to_openai_schema(self) -> dict[str, Any]:
106
+ """
107
+ OpenAI function-tool schema for chat completions.
108
+ """
109
+ schema = self.arguments_model.model_json_schema()
110
+ schema.pop("title", None)
111
+ return {
112
+ "type": "function",
113
+ "function": {
114
+ "name": self.name,
115
+ "description": self.description,
116
+ "parameters": schema,
117
+ },
118
+ }
@@ -0,0 +1,99 @@
1
+ """
2
+ Detectors for stuck thinking / tool-call loops.
3
+ """
4
+
5
+ from corekit.llm.tools.base import ToolCall
6
+ from corekit.observability import Loggable
7
+
8
+ __all__ = [
9
+ "MAX_TOOL_ITERATIONS",
10
+ "ToolCallLoopDetector",
11
+ ]
12
+
13
+ # Hard circuit breaker — stuck loops are usually caught earlier by
14
+ # ToolCallLoopDetector. Exists so a non-repeating pathological sequence cannot
15
+ # run forever.
16
+ MAX_TOOL_ITERATIONS = 50
17
+
18
+ _REPEAT_WINDOW = 400
19
+ _REPEAT_LOOKBACK = 2_000
20
+ _REPEAT_CALL_THRESHOLD = 3
21
+
22
+
23
+ class ToolCallLoopDetector(Loggable):
24
+ """
25
+ Catches a model stuck making no progress — identical calls or repeated
26
+ errors — without capping a long sequence of distinct productive calls.
27
+
28
+ ``Loggable`` rather than ``Benchmarkable``: this object decides and warns;
29
+ wall-clock timing of model turns belongs on ``ToolLoop``.
30
+ """
31
+
32
+ def __init__(self, threshold: int = _REPEAT_CALL_THRESHOLD) -> None:
33
+ super().__init__()
34
+ self._threshold = threshold
35
+ self._last_call_signature: str | None = None
36
+ self._repeat_call_count = 0
37
+ self._last_error_signature: str | None = None
38
+ self._repeat_error_count = 0
39
+
40
+ @staticmethod
41
+ def is_thinking_looping(
42
+ buffer: str,
43
+ *,
44
+ window: int = _REPEAT_WINDOW,
45
+ lookback: int = _REPEAT_LOOKBACK,
46
+ ) -> bool:
47
+ """
48
+ True if the tail of the thinking buffer is a repeated substring.
49
+ """
50
+ if len(buffer) < window * 2:
51
+ return False
52
+ tail = buffer[-window:]
53
+ prior = buffer[-(min(len(buffer), window + lookback)) : -window]
54
+ return tail in prior
55
+
56
+ def check_thinking(self, buffer: str) -> bool:
57
+ """
58
+ Return True (and log) when the thinking buffer is looping.
59
+ """
60
+ if not self.is_thinking_looping(buffer):
61
+ return False
62
+ self.warning(f"Thinking loop detected at {len(buffer)} chars")
63
+ return True
64
+
65
+ def record_call(self, call: ToolCall) -> bool:
66
+ """
67
+ Record a tool call; return True (and log) if it matches the last call
68
+ enough times.
69
+ """
70
+ signature = call.signature()
71
+ if signature == self._last_call_signature:
72
+ self._repeat_call_count += 1
73
+ else:
74
+ self._last_call_signature = signature
75
+ self._repeat_call_count = 1
76
+ if self._repeat_call_count < self._threshold:
77
+ return False
78
+ self.warning(f"Repeated identical call detected — {call.name!r} (x{self._repeat_call_count})")
79
+ return True
80
+
81
+ def record_result(self, call: ToolCall, is_error: bool) -> bool:
82
+ """
83
+ Record a tool result; return True (and log) if the same call keeps failing.
84
+ """
85
+ if not is_error:
86
+ self._last_error_signature = None
87
+ self._repeat_error_count = 0
88
+ return False
89
+
90
+ signature = call.signature()
91
+ if signature == self._last_error_signature:
92
+ self._repeat_error_count += 1
93
+ else:
94
+ self._last_error_signature = signature
95
+ self._repeat_error_count = 1
96
+ if self._repeat_error_count < self._threshold:
97
+ return False
98
+ self.warning(f"Repeated failing call detected — {call.name!r} (x{self._repeat_error_count})")
99
+ return True
@@ -0,0 +1,255 @@
1
+ """
2
+ Agentic tool-calling loop over an injected chat client.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from typing import Any
9
+
10
+ from corekit.llm.enum import StopReason, ToolKind
11
+ from corekit.llm.events import (
12
+ StopEvent,
13
+ StreamEvent,
14
+ ThinkingEvent,
15
+ ToolCallEvent,
16
+ ToolResultEvent,
17
+ UIComponentEvent,
18
+ )
19
+ from corekit.llm.messages import AssistantMessage, ChatTurn, Message
20
+ from corekit.llm.protocols import ChatClient, MetricsRecorder
21
+ from corekit.llm.streaming import ModelStreamingState
22
+ from corekit.llm.tools.base import ToolCall
23
+ from corekit.llm.tools.detection import MAX_TOOL_ITERATIONS, ToolCallLoopDetector
24
+ from corekit.llm.tools.registry import ToolRegistry
25
+ from corekit.llm.wire import ChoiceDelta, CompletionTurn, UsageInfo
26
+ from corekit.observability import Benchmarkable
27
+ from corekit.utils.coercion import safe_dict
28
+
29
+ __all__ = ["ToolLoop"]
30
+
31
+
32
+ class ToolLoop(Benchmarkable):
33
+ """
34
+ Streamed tool-calling loop against an OpenAI-compatible chat client.
35
+
36
+ DATA tools loop; UI tools halt. Plain-text turns stream live and exit.
37
+ The client is injected — this never imports a provider SDK.
38
+
39
+ Tool execution always goes through ``ToolRegistry.aexecute`` so I/O-bound
40
+ tools can override ``Tool.aexecute`` without blocking the event loop.
41
+
42
+ Per-turn timing uses ``Benchmarkable``: ``reset_timing()`` at turn start,
43
+ ``timing("first_token")`` on the first chunk, ``timing("turn_done")`` at
44
+ the end — so TTFT and total duration come from splits rather than ad-hoc
45
+ clocks.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ client: ChatClient,
51
+ registry: ToolRegistry,
52
+ *,
53
+ max_iterations: int = MAX_TOOL_ITERATIONS,
54
+ metrics: MetricsRecorder | None = None,
55
+ model: str | None = None,
56
+ ) -> None:
57
+ super().__init__()
58
+ self._client = client
59
+ self._registry = registry
60
+ self._max_iterations = max_iterations
61
+ self._metrics = metrics
62
+ self._model = model
63
+ self._loop_detector = ToolCallLoopDetector()
64
+
65
+ async def stream(
66
+ self,
67
+ messages: Sequence[ChatTurn],
68
+ *,
69
+ model: str | None = None,
70
+ internal: bool = False,
71
+ **complete_kwargs: Any,
72
+ ) -> AsyncIterator[StreamEvent]:
73
+ """
74
+ Run the loop starting from ``messages``. Yields stream events until
75
+ text completes, a UI tool halts, or a stop condition fires.
76
+
77
+ ``messages`` may be ``Message`` instances or raw OpenAI message dicts.
78
+ """
79
+ working: list[dict[str, Any]] = Message.as_wire(messages, internal=internal)
80
+ tools_schema = self._registry.openai_schemas() or None
81
+ resolved_model = model or self._model
82
+
83
+ self.info(f"[ToolLoop] Starting — tools: {[t.name for t in self._registry.list_tools()]}")
84
+
85
+ for iteration in range(self._max_iterations):
86
+ self.info(f"[ToolLoop] Iteration {iteration + 1}/{self._max_iterations}")
87
+ self.reset_timing()
88
+ state = ModelStreamingState()
89
+ turn = CompletionTurn()
90
+
91
+ async for event in self._stream_model_turn(
92
+ working,
93
+ state=state,
94
+ turn=turn,
95
+ tools_schema=tools_schema,
96
+ model=resolved_model,
97
+ **complete_kwargs,
98
+ ):
99
+ yield event
100
+ if isinstance(event, StopEvent):
101
+ return
102
+
103
+ turn_done = self.timing("turn_done")
104
+ self._record_metrics(turn, model=resolved_model, total_duration_ms=turn_done.total_ms)
105
+
106
+ if turn.usage is not None:
107
+ yield turn.usage.to_event()
108
+
109
+ self.info(
110
+ f"[ToolLoop] Turn done — finish_reason: {turn.finish_reason!r}, tool_calls: {len(turn.tool_calls)}"
111
+ )
112
+ self.info(f"[ToolLoop] Final State: {state.format()}")
113
+
114
+ if not turn.tool_calls:
115
+ return
116
+
117
+ tool_calls = turn.parsed_tool_calls()
118
+ working.append(AssistantMessage.with_tools(state.text_buffer.strip(), tool_calls).format())
119
+
120
+ async for event in self._dispatch_tool_calls(tool_calls, working):
121
+ yield event
122
+ if isinstance(event, (StopEvent, UIComponentEvent)):
123
+ return
124
+
125
+ self.warning(f"[ToolLoop] Hit max_iterations={self._max_iterations}")
126
+ yield StopEvent(reason=StopReason.MAX_ITERATIONS)
127
+
128
+ async def _stream_model_turn(
129
+ self,
130
+ working: list[dict[str, Any]],
131
+ *,
132
+ state: ModelStreamingState,
133
+ turn: CompletionTurn,
134
+ tools_schema: list[dict[str, Any]] | None,
135
+ model: str | None,
136
+ **complete_kwargs: Any,
137
+ ) -> AsyncIterator[StreamEvent]:
138
+ """
139
+ Stream one ``complete()`` call into events and update ``state`` / ``turn``.
140
+ """
141
+ self.info(f"[ToolLoop] Streaming — messages: {len(working)}, tools: {tools_schema is not None}")
142
+
143
+ # ToolLoop is a streaming consumer; never let callers flip stream off.
144
+ stream_kwargs = {k: v for k, v in complete_kwargs.items() if k != "stream"}
145
+ async for chunk in self._client.complete(
146
+ working,
147
+ tools=tools_schema,
148
+ model=model,
149
+ stream=True,
150
+ **stream_kwargs,
151
+ ):
152
+ if turn.first_token_ms is None:
153
+ turn.first_token_ms = self.timing("first_token").total_ms
154
+
155
+ turn.absorb_usage(UsageInfo.from_chunk(chunk))
156
+ delta = ChoiceDelta.from_chunk(chunk)
157
+ if delta is None:
158
+ continue
159
+
160
+ turn.absorb_delta(delta)
161
+
162
+ if delta.reasoning_content:
163
+ async for event in self._emit_native_reasoning(state, delta.reasoning_content):
164
+ yield event
165
+ if isinstance(event, StopEvent):
166
+ return
167
+
168
+ if delta.content:
169
+ for event in state.feed(delta.content):
170
+ yield event
171
+
172
+ async def _emit_native_reasoning(
173
+ self,
174
+ state: ModelStreamingState,
175
+ reasoning: str,
176
+ ) -> AsyncIterator[StreamEvent]:
177
+ """
178
+ Handle ``delta.reasoning_content`` (llama.cpp native thinking field).
179
+ """
180
+ state.think_buffer += reasoning
181
+ if self._loop_detector.check_thinking(state.think_buffer):
182
+ yield StopEvent(reason=StopReason.LOOP_DETECTED)
183
+ return
184
+ yield ThinkingEvent(content=reasoning)
185
+
186
+ async def _dispatch_tool_calls(
187
+ self,
188
+ tool_calls: list[ToolCall],
189
+ working: list[dict[str, Any]],
190
+ ) -> AsyncIterator[StreamEvent]:
191
+ """
192
+ Execute parsed tool calls; append DATA results onto ``working``.
193
+ """
194
+ self.info(f"[ToolLoop] Parsed calls: {[c.name for c in tool_calls]}")
195
+
196
+ for call in tool_calls:
197
+ self.info(f"[ToolLoop] Executing — {call.name!r}")
198
+ yield ToolCallEvent(tool_call=call)
199
+
200
+ if self._loop_detector.record_call(call):
201
+ yield StopEvent(reason=StopReason.LOOP_DETECTED)
202
+ return
203
+
204
+ kind = self._registry.get_tool_kind(call.name)
205
+ self.info(f"[ToolLoop] Kind: {kind!r}")
206
+
207
+ if kind == ToolKind.UI:
208
+ self.info("[ToolLoop] UI tool — halting")
209
+ yield await self._execute_ui_tool(call)
210
+ return
211
+
212
+ if kind is None:
213
+ self.warning(f"[ToolLoop] Unknown tool {call.name!r}")
214
+
215
+ result = await self._registry.aexecute(call.name, call.id, call.arguments)
216
+ self.info(f"[ToolLoop] DATA result — is_error: {result.is_error}")
217
+ yield ToolResultEvent(tool_result=result)
218
+ working.append(result.to_openai_message())
219
+
220
+ if self._loop_detector.record_result(call, result.is_error):
221
+ yield StopEvent(reason=StopReason.LOOP_DETECTED)
222
+ return
223
+
224
+ async def _execute_ui_tool(self, call: ToolCall) -> UIComponentEvent:
225
+ result = await self._registry.aexecute(call.name, call.id, call.arguments)
226
+ self.info(f"[ToolLoop] UI result — is_error: {result.is_error}")
227
+ if result.is_error:
228
+ self.warning(f"[ToolLoop] UI tool failed: {result.content}")
229
+ return UIComponentEvent(
230
+ tool_call_id=call.id,
231
+ name=call.name,
232
+ component=safe_dict(result.content),
233
+ )
234
+
235
+ def _record_metrics(
236
+ self,
237
+ turn: CompletionTurn,
238
+ *,
239
+ model: str | None,
240
+ total_duration_ms: float,
241
+ succeeded: bool = True,
242
+ ) -> None:
243
+ if self._metrics is None:
244
+ return
245
+ usage = turn.usage
246
+ self._metrics.record(
247
+ model=model,
248
+ streamed=True,
249
+ succeeded=succeeded,
250
+ prompt_tokens=usage.prompt_tokens if usage else None,
251
+ completion_tokens=usage.completion_tokens if usage else None,
252
+ total_tokens=usage.total_tokens if usage else None,
253
+ time_to_first_token_ms=turn.first_token_ms,
254
+ total_duration_ms=total_duration_ms,
255
+ )