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.
Files changed (107) hide show
  1. corekit/api/application.py +47 -9
  2. corekit/api/lifespan.py +26 -3
  3. corekit/concurrency/__init__.py +2 -2
  4. corekit/concurrency/decorators.py +32 -5
  5. corekit/concurrency/thread_local.py +2 -2
  6. corekit/concurrency/worker.py +9 -0
  7. corekit/config/loader.py +42 -5
  8. corekit/config/settings.py +11 -1
  9. corekit/connections/__init__.py +7 -1
  10. corekit/connections/connectable.py +45 -4
  11. corekit/connections/redis/connection.py +53 -10
  12. corekit/connections/sql/__init__.py +2 -1
  13. corekit/connections/sql/connection.py +39 -5
  14. corekit/connections/sql/fields/__init__.py +2 -2
  15. corekit/connections/sql/fields/jsonb.py +13 -6
  16. corekit/connections/sql/migration/__init__.py +4 -0
  17. corekit/connections/sql/migration/operations.py +69 -2
  18. corekit/connections/sql/operations/base.py +11 -2
  19. corekit/connections/sql/operations/statements.py +25 -5
  20. corekit/connections/sql/table.py +7 -29
  21. corekit/crypto/__init__.py +3 -1
  22. corekit/crypto/constants.py +2 -2
  23. corekit/crypto/hasher.py +9 -4
  24. corekit/data/dataset.py +8 -2
  25. corekit/data/expressions/__init__.py +3 -3
  26. corekit/data/expressions/comparison.py +19 -80
  27. corekit/data/expressions/expression.py +0 -32
  28. corekit/data/expressions/operator.py +13 -28
  29. corekit/data/stats.py +3 -0
  30. corekit/decorators/exception_handling.py +36 -8
  31. corekit/docker/watchdog.py +50 -31
  32. corekit/etl/__init__.py +2 -1
  33. corekit/etl/connection.py +14 -12
  34. corekit/etl/extract/extractor.py +6 -13
  35. corekit/etl/orchestrator.py +19 -2
  36. corekit/etl/schemas.py +2 -2
  37. corekit/etl/transform/transformer.py +4 -1
  38. corekit/events/publisher.py +1 -1
  39. corekit/events/reader.py +26 -21
  40. corekit/events/sse.py +4 -1
  41. corekit/events/websocket.py +24 -11
  42. corekit/exceptions/__init__.py +24 -9
  43. corekit/exceptions/base.py +139 -10
  44. corekit/exceptions/enum.py +17 -0
  45. corekit/exceptions/types.py +6 -6
  46. corekit/files/__init__.py +2 -4
  47. corekit/files/base.py +15 -2
  48. corekit/files/enum.py +0 -5
  49. corekit/files/json.py +16 -2
  50. corekit/http/__init__.py +48 -5
  51. corekit/http/api.py +24 -0
  52. corekit/http/client.py +133 -75
  53. corekit/http/exceptions.py +140 -0
  54. corekit/http/response.py +50 -1
  55. corekit/http/status.py +89 -0
  56. corekit/http/stream.py +110 -0
  57. corekit/jobs/runner.py +12 -1
  58. corekit/jobs/task.py +23 -2
  59. corekit/llm/__init__.py +134 -0
  60. corekit/llm/client.py +179 -0
  61. corekit/llm/enum.py +123 -0
  62. corekit/llm/events.py +96 -0
  63. corekit/llm/messages.py +173 -0
  64. corekit/llm/prompts/__init__.py +19 -0
  65. corekit/llm/prompts/enum.py +54 -0
  66. corekit/llm/prompts/exceptions.py +22 -0
  67. corekit/llm/prompts/loader.py +139 -0
  68. corekit/llm/prompts/template.py +53 -0
  69. corekit/llm/protocols.py +65 -0
  70. corekit/llm/streaming.py +149 -0
  71. corekit/llm/tools/__init__.py +19 -0
  72. corekit/llm/tools/base.py +118 -0
  73. corekit/llm/tools/detection.py +99 -0
  74. corekit/llm/tools/loop.py +255 -0
  75. corekit/llm/tools/registry.py +103 -0
  76. corekit/llm/wire.py +199 -0
  77. corekit/log_monitor/models.py +8 -2
  78. corekit/log_monitor/service.py +77 -38
  79. corekit/notifications/base.py +18 -10
  80. corekit/observability/__init__.py +12 -5
  81. corekit/observability/benchmarkable.py +37 -5
  82. corekit/observability/loggable.py +21 -0
  83. corekit/observability/request_context.py +55 -2
  84. corekit/observability/timing/split.py +14 -0
  85. corekit/observability/timing/timer.py +33 -9
  86. corekit/registry/__init__.py +2 -2
  87. corekit/registry/registry.py +55 -14
  88. corekit/schemas/__init__.py +2 -1
  89. corekit/schemas/enum.py +22 -1
  90. corekit/schemas/types.py +6 -1
  91. corekit/schemas/version.py +58 -0
  92. corekit/serialization/__init__.py +2 -0
  93. corekit/serialization/pickle_file.py +61 -0
  94. corekit/serialization/serializable.py +22 -2
  95. corekit/serialization/serializer.py +9 -2
  96. corekit/utils/__init__.py +2 -1
  97. corekit/utils/collections.py +38 -14
  98. corekit/utils/payload.py +12 -0
  99. {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/METADATA +38 -9
  100. python_corekit-0.4.0.dist-info/RECORD +165 -0
  101. corekit/constants.py +0 -45
  102. corekit/exceptions/http/exceptions.py +0 -37
  103. corekit/files/pickle.py +0 -12
  104. python_corekit-0.2.0.dist-info/RECORD +0 -143
  105. {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/WHEEL +0 -0
  106. {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/licenses/LICENSE +0 -0
  107. {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
+ )