python-corekit 0.2.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/api/application.py +47 -9
- corekit/api/lifespan.py +26 -3
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +9 -0
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +2 -1
- corekit/connections/sql/connection.py +39 -5
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +4 -0
- corekit/connections/sql/migration/operations.py +69 -2
- corekit/connections/sql/operations/base.py +11 -2
- corekit/connections/sql/operations/statements.py +25 -5
- corekit/connections/sql/table.py +7 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +3 -3
- corekit/data/expressions/comparison.py +19 -80
- corekit/data/expressions/expression.py +0 -32
- corekit/data/expressions/operator.py +13 -28
- corekit/data/stats.py +3 -0
- corekit/decorators/exception_handling.py +36 -8
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +14 -12
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +24 -11
- corekit/exceptions/__init__.py +24 -9
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +48 -5
- corekit/http/api.py +24 -0
- corekit/http/client.py +133 -75
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/http/stream.py +110 -0
- corekit/jobs/runner.py +12 -1
- corekit/jobs/task.py +23 -2
- 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/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -5
- corekit/observability/benchmarkable.py +37 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +55 -2
- corekit/observability/timing/split.py +14 -0
- corekit/observability/timing/timer.py +33 -9
- corekit/registry/__init__.py +2 -2
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +2 -1
- corekit/schemas/enum.py +22 -1
- corekit/schemas/types.py +6 -1
- corekit/schemas/version.py +58 -0
- corekit/serialization/__init__.py +2 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +9 -2
- corekit/utils/__init__.py +2 -1
- corekit/utils/collections.py +38 -14
- corekit/utils/payload.py +12 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/METADATA +38 -9
- python_corekit-0.4.0.dist-info/RECORD +165 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.2.0.dist-info/RECORD +0 -143
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry of callable tools for an agentic loop.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from corekit.llm.enum import ToolKind
|
|
9
|
+
from corekit.llm.tools.base import Tool, ToolResult
|
|
10
|
+
from corekit.registry import SmartRegistry
|
|
11
|
+
|
|
12
|
+
__all__ = ["ToolRegistry"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolRegistry(SmartRegistry):
|
|
16
|
+
"""
|
|
17
|
+
Named tools available to a chat completion. Starts empty — the consumer
|
|
18
|
+
registers what it wants; there is no default population here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def register(self, tool: Tool) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Add a tool under its ``name``. Raises if the name is already taken.
|
|
24
|
+
"""
|
|
25
|
+
if tool.name in self:
|
|
26
|
+
raise ValueError(f"Tool already registered: {tool.name}")
|
|
27
|
+
self[tool.name] = tool
|
|
28
|
+
|
|
29
|
+
def list_tools(self) -> list[Tool]:
|
|
30
|
+
"""
|
|
31
|
+
All registered tools, in registry iteration order.
|
|
32
|
+
"""
|
|
33
|
+
return list(self.values())
|
|
34
|
+
|
|
35
|
+
def filtered(self, predicate: Callable[[Tool], bool]) -> "ToolRegistry":
|
|
36
|
+
"""
|
|
37
|
+
Return a new registry containing only tools matching ``predicate``.
|
|
38
|
+
|
|
39
|
+
Does not mutate this registry. Use this instead of product-specific
|
|
40
|
+
scope enums — the consumer owns the filter (e.g. by attribute or tag).
|
|
41
|
+
"""
|
|
42
|
+
result = ToolRegistry()
|
|
43
|
+
for tool in self.values():
|
|
44
|
+
if predicate(tool):
|
|
45
|
+
result[tool.name] = tool
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def openai_schemas(self) -> list[dict[str, Any]]:
|
|
49
|
+
"""
|
|
50
|
+
OpenAI function-tool schemas for every registered tool.
|
|
51
|
+
"""
|
|
52
|
+
return [t.to_openai_schema() for t in self.values()]
|
|
53
|
+
|
|
54
|
+
def execute(self, name: str, tool_call_id: str, arguments: dict[str, Any]) -> ToolResult:
|
|
55
|
+
"""
|
|
56
|
+
Sync lookup and run. Prefer ``aexecute`` inside async loops.
|
|
57
|
+
"""
|
|
58
|
+
tool = self.get(name)
|
|
59
|
+
if tool is None:
|
|
60
|
+
return self._unknown_result(name, tool_call_id)
|
|
61
|
+
try:
|
|
62
|
+
return tool.execute(arguments).with_call_id(tool_call_id)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return self._error_result(name, tool_call_id, e)
|
|
65
|
+
|
|
66
|
+
async def aexecute(self, name: str, tool_call_id: str, arguments: dict[str, Any]) -> ToolResult:
|
|
67
|
+
"""
|
|
68
|
+
Async lookup and run via ``Tool.aexecute``.
|
|
69
|
+
"""
|
|
70
|
+
tool = self.get(name)
|
|
71
|
+
if tool is None:
|
|
72
|
+
return self._unknown_result(name, tool_call_id)
|
|
73
|
+
try:
|
|
74
|
+
result = await tool.aexecute(arguments)
|
|
75
|
+
return result.with_call_id(tool_call_id)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return self._error_result(name, tool_call_id, e)
|
|
78
|
+
|
|
79
|
+
def get_tool_kind(self, name: str) -> ToolKind | None:
|
|
80
|
+
"""
|
|
81
|
+
Kind of the named tool, or None if unregistered.
|
|
82
|
+
"""
|
|
83
|
+
if tool := self.get(name):
|
|
84
|
+
return tool.kind
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _unknown_result(name: str, tool_call_id: str) -> ToolResult:
|
|
89
|
+
return ToolResult(
|
|
90
|
+
tool_call_id=tool_call_id,
|
|
91
|
+
name=name,
|
|
92
|
+
content=f"Unknown tool: {name}",
|
|
93
|
+
is_error=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _error_result(name: str, tool_call_id: str, error: Exception) -> ToolResult:
|
|
98
|
+
return ToolResult(
|
|
99
|
+
tool_call_id=tool_call_id,
|
|
100
|
+
name=name,
|
|
101
|
+
content=f"Tool execution error: {error}",
|
|
102
|
+
is_error=True,
|
|
103
|
+
)
|