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.
- corekit/http/__init__.py +5 -0
- corekit/http/client.py +34 -3
- corekit/http/stream.py +110 -0
- corekit/llm/__init__.py +134 -0
- corekit/llm/client.py +179 -0
- corekit/llm/enum.py +123 -0
- corekit/llm/events.py +96 -0
- corekit/llm/messages.py +173 -0
- corekit/llm/prompts/__init__.py +19 -0
- corekit/llm/prompts/enum.py +54 -0
- corekit/llm/prompts/exceptions.py +22 -0
- corekit/llm/prompts/loader.py +139 -0
- corekit/llm/prompts/template.py +53 -0
- corekit/llm/protocols.py +65 -0
- corekit/llm/streaming.py +149 -0
- corekit/llm/tools/__init__.py +19 -0
- corekit/llm/tools/base.py +118 -0
- corekit/llm/tools/detection.py +99 -0
- corekit/llm/tools/loop.py +255 -0
- corekit/llm/tools/registry.py +103 -0
- corekit/llm/wire.py +199 -0
- corekit/observability/__init__.py +3 -3
- corekit/observability/benchmarkable.py +16 -2
- corekit/observability/timing/split.py +14 -0
- corekit/observability/timing/timer.py +31 -9
- corekit/schemas/__init__.py +2 -1
- corekit/schemas/version.py +58 -0
- corekit/utils/__init__.py +2 -1
- corekit/utils/collections.py +16 -1
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/METADATA +33 -4
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/RECORD +34 -14
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/top_level.txt +0 -0
corekit/llm/streaming.py
ADDED
|
@@ -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
|
+
)
|