cortexflow-sdk 0.1.0__tar.gz
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.
- cortexflow_sdk-0.1.0/LICENSE +21 -0
- cortexflow_sdk-0.1.0/PKG-INFO +109 -0
- cortexflow_sdk-0.1.0/README.md +95 -0
- cortexflow_sdk-0.1.0/pyproject.toml +25 -0
- cortexflow_sdk-0.1.0/setup.cfg +4 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk/__init__.py +38 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk/channels.py +99 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk/plugins.py +101 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk/tools.py +116 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk.egg-info/PKG-INFO +109 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk.egg-info/SOURCES.txt +15 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk.egg-info/dependency_links.txt +1 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk.egg-info/requires.txt +4 -0
- cortexflow_sdk-0.1.0/src/cortexflow_sdk.egg-info/top_level.txt +1 -0
- cortexflow_sdk-0.1.0/tests/test_channels.py +92 -0
- cortexflow_sdk-0.1.0/tests/test_plugins.py +90 -0
- cortexflow_sdk-0.1.0/tests/test_tools.py +73 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amit Chandra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cortexflow-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed interfaces for building CortexFlow plugins, tools, and channel adapters — no gateway dependencies required.
|
|
5
|
+
Author-email: Amit Chandra <amit.vervebot@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.2.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# cortexflow-sdk
|
|
16
|
+
|
|
17
|
+
Typed interfaces for building [CortexFlow](https://github.com/TheAmitChandra/CortexFlow) plugins — without installing the full gateway.
|
|
18
|
+
|
|
19
|
+
CortexFlow's gateway depends on FastAPI, Redis, Qdrant, and SDKs for all 14
|
|
20
|
+
supported channels. None of that is needed to *write* a plugin — only to
|
|
21
|
+
*run* the gateway that loads it. This package contains just the three base
|
|
22
|
+
classes a plugin author needs, with zero third-party dependencies.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install cortexflow-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Writing a plugin
|
|
31
|
+
|
|
32
|
+
Every plugin package registers a `Plugin` subclass via a `cortexflow.plugins`
|
|
33
|
+
entry point:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# my_plugin/plugin.py
|
|
37
|
+
from cortexflow_sdk import Plugin, PluginMetadata, Tool, ToolResult
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WeatherTool(Tool):
|
|
41
|
+
name = "get_weather"
|
|
42
|
+
description = "Get the current weather for a city."
|
|
43
|
+
parameters = {
|
|
44
|
+
"city": {"type": "str", "description": "City name", "required": True},
|
|
45
|
+
}
|
|
46
|
+
permissions = ["network"]
|
|
47
|
+
|
|
48
|
+
async def execute(self, city: str) -> ToolResult:
|
|
49
|
+
# ... call a weather API ...
|
|
50
|
+
return ToolResult(tool=self.name, output=f"Sunny in {city}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WeatherPlugin(Plugin):
|
|
54
|
+
metadata = PluginMetadata(
|
|
55
|
+
name="cortexflow-weather",
|
|
56
|
+
version="1.0.0",
|
|
57
|
+
plugin_type="tool",
|
|
58
|
+
description="Adds a get_weather tool.",
|
|
59
|
+
permissions=["network"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def get_tools(self):
|
|
63
|
+
return [WeatherTool()]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```toml
|
|
67
|
+
# my_plugin/pyproject.toml
|
|
68
|
+
[project.entry-points."cortexflow.plugins"]
|
|
69
|
+
cortexflow-weather = "my_plugin.plugin:WeatherPlugin"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Once published to PyPI and installed alongside the CortexFlow gateway,
|
|
73
|
+
`cortex plugin add cortexflow-weather` discovers and loads it.
|
|
74
|
+
|
|
75
|
+
## Plugin types
|
|
76
|
+
|
|
77
|
+
| `plugin_type` | Implement | Contributes |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `tool` | `get_tools()` | One or more `Tool` instances |
|
|
80
|
+
| `channel` | `get_channel_adapter()` | A `ChannelAdapter` instance |
|
|
81
|
+
| `tts` / `stt` / `memory` | — | Loaded by name; see gateway docs |
|
|
82
|
+
| `generic` | `on_load()` / `on_unload()` | Lifecycle hooks only |
|
|
83
|
+
|
|
84
|
+
## Writing a channel adapter plugin
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from cortexflow_sdk import ChannelAdapter, InboundMessage
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MyChannelAdapter(ChannelAdapter):
|
|
91
|
+
channel_id = "my_channel"
|
|
92
|
+
|
|
93
|
+
async def connect(self) -> None: ...
|
|
94
|
+
async def disconnect(self) -> None: ...
|
|
95
|
+
async def send(self, target, text, *, reply_to=None, attachments=None):
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
async def _on_platform_event(self, raw_event: dict) -> None:
|
|
99
|
+
await self._dispatch(InboundMessage(
|
|
100
|
+
channel=self.channel_id,
|
|
101
|
+
sender_id=raw_event["user_id"],
|
|
102
|
+
sender_name=raw_event["user_name"],
|
|
103
|
+
text=raw_event["text"],
|
|
104
|
+
))
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# cortexflow-sdk
|
|
2
|
+
|
|
3
|
+
Typed interfaces for building [CortexFlow](https://github.com/TheAmitChandra/CortexFlow) plugins — without installing the full gateway.
|
|
4
|
+
|
|
5
|
+
CortexFlow's gateway depends on FastAPI, Redis, Qdrant, and SDKs for all 14
|
|
6
|
+
supported channels. None of that is needed to *write* a plugin — only to
|
|
7
|
+
*run* the gateway that loads it. This package contains just the three base
|
|
8
|
+
classes a plugin author needs, with zero third-party dependencies.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install cortexflow-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Writing a plugin
|
|
17
|
+
|
|
18
|
+
Every plugin package registers a `Plugin` subclass via a `cortexflow.plugins`
|
|
19
|
+
entry point:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
# my_plugin/plugin.py
|
|
23
|
+
from cortexflow_sdk import Plugin, PluginMetadata, Tool, ToolResult
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WeatherTool(Tool):
|
|
27
|
+
name = "get_weather"
|
|
28
|
+
description = "Get the current weather for a city."
|
|
29
|
+
parameters = {
|
|
30
|
+
"city": {"type": "str", "description": "City name", "required": True},
|
|
31
|
+
}
|
|
32
|
+
permissions = ["network"]
|
|
33
|
+
|
|
34
|
+
async def execute(self, city: str) -> ToolResult:
|
|
35
|
+
# ... call a weather API ...
|
|
36
|
+
return ToolResult(tool=self.name, output=f"Sunny in {city}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WeatherPlugin(Plugin):
|
|
40
|
+
metadata = PluginMetadata(
|
|
41
|
+
name="cortexflow-weather",
|
|
42
|
+
version="1.0.0",
|
|
43
|
+
plugin_type="tool",
|
|
44
|
+
description="Adds a get_weather tool.",
|
|
45
|
+
permissions=["network"],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def get_tools(self):
|
|
49
|
+
return [WeatherTool()]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```toml
|
|
53
|
+
# my_plugin/pyproject.toml
|
|
54
|
+
[project.entry-points."cortexflow.plugins"]
|
|
55
|
+
cortexflow-weather = "my_plugin.plugin:WeatherPlugin"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Once published to PyPI and installed alongside the CortexFlow gateway,
|
|
59
|
+
`cortex plugin add cortexflow-weather` discovers and loads it.
|
|
60
|
+
|
|
61
|
+
## Plugin types
|
|
62
|
+
|
|
63
|
+
| `plugin_type` | Implement | Contributes |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `tool` | `get_tools()` | One or more `Tool` instances |
|
|
66
|
+
| `channel` | `get_channel_adapter()` | A `ChannelAdapter` instance |
|
|
67
|
+
| `tts` / `stt` / `memory` | — | Loaded by name; see gateway docs |
|
|
68
|
+
| `generic` | `on_load()` / `on_unload()` | Lifecycle hooks only |
|
|
69
|
+
|
|
70
|
+
## Writing a channel adapter plugin
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from cortexflow_sdk import ChannelAdapter, InboundMessage
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MyChannelAdapter(ChannelAdapter):
|
|
77
|
+
channel_id = "my_channel"
|
|
78
|
+
|
|
79
|
+
async def connect(self) -> None: ...
|
|
80
|
+
async def disconnect(self) -> None: ...
|
|
81
|
+
async def send(self, target, text, *, reply_to=None, attachments=None):
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
async def _on_platform_event(self, raw_event: dict) -> None:
|
|
85
|
+
await self._dispatch(InboundMessage(
|
|
86
|
+
channel=self.channel_id,
|
|
87
|
+
sender_id=raw_event["user_id"],
|
|
88
|
+
sender_name=raw_event["user_name"],
|
|
89
|
+
text=raw_event["text"],
|
|
90
|
+
))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cortexflow-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed interfaces for building CortexFlow plugins, tools, and channel adapters — no gateway dependencies required."
|
|
9
|
+
authors = [{ name = "Amit Chandra", email = "amit.vervebot@gmail.com" }]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.2.0",
|
|
18
|
+
"pytest-asyncio>=0.23.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""cortexflow-sdk — typed interfaces for building CortexFlow plugins.
|
|
2
|
+
|
|
3
|
+
Install this package (not the full ``cortexflow`` gateway) to write a
|
|
4
|
+
plugin, tool, or channel adapter that CortexFlow can load::
|
|
5
|
+
|
|
6
|
+
pip install cortexflow-sdk
|
|
7
|
+
|
|
8
|
+
Then subclass one of:
|
|
9
|
+
|
|
10
|
+
Plugin — the entry point every plugin package registers
|
|
11
|
+
Tool — a discrete capability the agent can invoke
|
|
12
|
+
ChannelAdapter — a messaging platform integration
|
|
13
|
+
|
|
14
|
+
See https://github.com/TheAmitChandra/CortexFlow for the full gateway
|
|
15
|
+
and plugin-loading documentation.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from cortexflow_sdk.channels import (
|
|
19
|
+
Attachment,
|
|
20
|
+
ChannelAdapter,
|
|
21
|
+
InboundMessage,
|
|
22
|
+
MessageHandler,
|
|
23
|
+
)
|
|
24
|
+
from cortexflow_sdk.plugins import Plugin, PluginMetadata
|
|
25
|
+
from cortexflow_sdk.tools import Tool, ToolResult
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Attachment",
|
|
29
|
+
"ChannelAdapter",
|
|
30
|
+
"InboundMessage",
|
|
31
|
+
"MessageHandler",
|
|
32
|
+
"Plugin",
|
|
33
|
+
"PluginMetadata",
|
|
34
|
+
"Tool",
|
|
35
|
+
"ToolResult",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Channel adapter abstract base class and shared data types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Attachment:
|
|
13
|
+
"""A file or media attachment from a channel message."""
|
|
14
|
+
|
|
15
|
+
type: str # "image" | "audio" | "video" | "document"
|
|
16
|
+
url: str | None = None
|
|
17
|
+
data: bytes | None = None
|
|
18
|
+
filename: str | None = None
|
|
19
|
+
mime_type: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InboundMessage:
|
|
24
|
+
"""Normalised inbound message from any channel adapter."""
|
|
25
|
+
|
|
26
|
+
channel: str
|
|
27
|
+
sender_id: str
|
|
28
|
+
sender_name: str
|
|
29
|
+
text: str | None
|
|
30
|
+
attachments: list[Attachment] = field(default_factory=list)
|
|
31
|
+
thread_id: str | None = None
|
|
32
|
+
reply_to_id: str | None = None
|
|
33
|
+
timestamp: float = field(default_factory=time.time)
|
|
34
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: A coroutine function that handles an inbound message.
|
|
38
|
+
MessageHandler = Callable[[InboundMessage], Awaitable[None]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ChannelAdapter(ABC):
|
|
42
|
+
"""Abstract base for all platform channel adapters.
|
|
43
|
+
|
|
44
|
+
Subclasses must set ``channel_id`` as a class attribute and implement
|
|
45
|
+
``connect``, ``disconnect``, and ``send``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
channel_id: str # e.g. "telegram" | "discord" | "slack"
|
|
49
|
+
|
|
50
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
51
|
+
self.config = config
|
|
52
|
+
self._handler: MessageHandler | None = None
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def connect(self) -> None:
|
|
56
|
+
"""Establish connection to the platform. Raises on failure."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
async def disconnect(self) -> None:
|
|
61
|
+
"""Gracefully disconnect from the platform."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def send(
|
|
66
|
+
self,
|
|
67
|
+
target: str,
|
|
68
|
+
text: str,
|
|
69
|
+
*,
|
|
70
|
+
reply_to: str | None = None,
|
|
71
|
+
attachments: list[Attachment] | None = None,
|
|
72
|
+
) -> str | None:
|
|
73
|
+
"""Send a message to *target* (platform-specific ID).
|
|
74
|
+
|
|
75
|
+
Returns the sent message ID if the platform provides one.
|
|
76
|
+
"""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def on_message(self, handler: MessageHandler) -> None:
|
|
80
|
+
"""Register the handler that receives all inbound messages."""
|
|
81
|
+
self._handler = handler
|
|
82
|
+
|
|
83
|
+
async def _dispatch(self, message: InboundMessage) -> None:
|
|
84
|
+
"""Forward *message* to the registered handler (if any)."""
|
|
85
|
+
if self._handler is not None:
|
|
86
|
+
await self._handler(message)
|
|
87
|
+
|
|
88
|
+
def get_config_schema(self) -> dict[str, Any]:
|
|
89
|
+
"""Return a JSON Schema describing this adapter's config options."""
|
|
90
|
+
return {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"enabled": {"type": "boolean", "default": False},
|
|
94
|
+
},
|
|
95
|
+
"required": [],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return f"{self.__class__.__name__}(channel_id={self.channel_id!r})"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Plugin base class — the contract every CortexFlow plugin must satisfy.
|
|
2
|
+
|
|
3
|
+
A plugin is a Python package installed via ``pip install <name>`` that:
|
|
4
|
+
- Declares a ``cortexflow.plugins`` entry point pointing to a Plugin subclass
|
|
5
|
+
- Provides one or more tools, channel adapters, or TTS/STT backends
|
|
6
|
+
- Declares its capabilities and required permissions upfront
|
|
7
|
+
|
|
8
|
+
Plugin types:
|
|
9
|
+
"tool" — adds Tool instances to the gateway's ToolRegistry
|
|
10
|
+
"channel" — adds a ChannelAdapter to the gateway
|
|
11
|
+
"tts" — alternative TTS backend
|
|
12
|
+
"stt" — alternative STT backend
|
|
13
|
+
"memory" — alternative memory tier
|
|
14
|
+
"generic" — anything else (lifecycle hooks, middleware)
|
|
15
|
+
|
|
16
|
+
Example plugin (in a separate package)::
|
|
17
|
+
|
|
18
|
+
# cortexflow_github/plugin.py
|
|
19
|
+
from cortexflow_sdk import Plugin, PluginMetadata
|
|
20
|
+
|
|
21
|
+
class GitHubPlugin(Plugin):
|
|
22
|
+
metadata = PluginMetadata(
|
|
23
|
+
name="cortexflow-github",
|
|
24
|
+
version="1.0.0",
|
|
25
|
+
plugin_type="tool",
|
|
26
|
+
description="GitHub integration — PRs, issues, commits",
|
|
27
|
+
permissions=["network"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def get_tools(self):
|
|
31
|
+
from .tools import GitHubTool
|
|
32
|
+
return [GitHubTool()]
|
|
33
|
+
|
|
34
|
+
# In pyproject.toml:
|
|
35
|
+
# [project.entry-points."cortexflow.plugins"]
|
|
36
|
+
# cortexflow-github = "cortexflow_github.plugin:GitHubPlugin"
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from abc import ABC
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from cortexflow_sdk.channels import ChannelAdapter
|
|
47
|
+
from cortexflow_sdk.tools import Tool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class PluginMetadata:
|
|
52
|
+
"""Metadata that every plugin must declare."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
version: str
|
|
56
|
+
plugin_type: str # "tool" | "channel" | "tts" | "stt" | "memory" | "generic"
|
|
57
|
+
description: str
|
|
58
|
+
permissions: list[str] = field(default_factory=list)
|
|
59
|
+
author: str = ""
|
|
60
|
+
homepage: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Plugin(ABC):
|
|
64
|
+
"""Abstract base for all CortexFlow plugins.
|
|
65
|
+
|
|
66
|
+
Subclass this and implement the methods that apply to your plugin_type.
|
|
67
|
+
Unimplemented optional methods return empty lists/None by default.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
metadata: PluginMetadata
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Lifecycle
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
async def on_load(self) -> None:
|
|
77
|
+
"""Called once when the plugin is loaded. Perform async init here."""
|
|
78
|
+
|
|
79
|
+
async def on_unload(self) -> None:
|
|
80
|
+
"""Called when the plugin is unloaded (e.g. on gateway shutdown)."""
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Type-specific contribution methods
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def get_tools(self) -> list["Tool"]:
|
|
87
|
+
"""Return Tool instances contributed by this plugin (type=tool)."""
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
def get_channel_adapter(self) -> "ChannelAdapter | None":
|
|
91
|
+
"""Return a ChannelAdapter contributed by this plugin (type=channel)."""
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def get_config_schema(self) -> dict[str, Any]:
|
|
95
|
+
"""Return JSON Schema for plugin-specific config options."""
|
|
96
|
+
return {"type": "object", "properties": {}}
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"Plugin({self.metadata.name!r} v{self.metadata.version}, type={self.metadata.plugin_type!r})"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Tool abstract base class and shared data types.
|
|
2
|
+
|
|
3
|
+
A Tool is a discrete capability an agent can invoke during a pipeline run.
|
|
4
|
+
Tools are:
|
|
5
|
+
- Declared with a name, description, and typed parameter schema
|
|
6
|
+
- Invoked with a plain dict of arguments
|
|
7
|
+
- Sandboxed: each tool declares what permissions it needs
|
|
8
|
+
- Stateless: tools must not store state between calls
|
|
9
|
+
|
|
10
|
+
Tool authors subclass ``Tool`` and implement ``execute``.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
from cortexflow_sdk import Tool, ToolResult
|
|
15
|
+
|
|
16
|
+
class MyTool(Tool):
|
|
17
|
+
name = "my_tool"
|
|
18
|
+
description = "Does something useful."
|
|
19
|
+
parameters = {
|
|
20
|
+
"query": {"type": "str", "description": "The search query"},
|
|
21
|
+
}
|
|
22
|
+
permissions = ["network"]
|
|
23
|
+
|
|
24
|
+
async def execute(self, query: str) -> ToolResult:
|
|
25
|
+
result = await some_api(query)
|
|
26
|
+
return ToolResult(output=result, tool=self.name)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from abc import ABC, abstractmethod
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ToolResult:
|
|
38
|
+
"""Returned by every tool execution."""
|
|
39
|
+
|
|
40
|
+
tool: str
|
|
41
|
+
output: Any
|
|
42
|
+
error: str | None = None
|
|
43
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def success(self) -> bool:
|
|
47
|
+
return self.error is None
|
|
48
|
+
|
|
49
|
+
def to_prompt_block(self) -> str:
|
|
50
|
+
"""Serialise for injection into the LLM prompt."""
|
|
51
|
+
if self.error:
|
|
52
|
+
return f"[TOOL:{self.tool} ERROR] {self.error}"
|
|
53
|
+
output = str(self.output) if not isinstance(self.output, str) else self.output
|
|
54
|
+
return f"[TOOL:{self.tool}]\n{output}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Tool(ABC):
|
|
58
|
+
"""Abstract base for all CortexFlow tools.
|
|
59
|
+
|
|
60
|
+
Class attributes (declare on subclass):
|
|
61
|
+
name: Unique tool identifier, snake_case.
|
|
62
|
+
description: One sentence used by the LLM to decide when to call it.
|
|
63
|
+
parameters: Dict of {param_name: {"type": str, "description": str, "required": bool}}.
|
|
64
|
+
permissions: List of required permission strings, e.g. ["network", "filesystem:read"].
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
description: str
|
|
69
|
+
parameters: dict[str, dict[str, Any]] = {}
|
|
70
|
+
permissions: list[str] = []
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
74
|
+
"""Run the tool with the given arguments.
|
|
75
|
+
|
|
76
|
+
Args should match the keys declared in ``parameters``.
|
|
77
|
+
Returns a ToolResult — never raises; wrap errors in ToolResult.error.
|
|
78
|
+
"""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
def get_schema(self) -> dict[str, Any]:
|
|
82
|
+
"""Return JSON Schema describing the tool for LLM function calling."""
|
|
83
|
+
props: dict[str, Any] = {}
|
|
84
|
+
required: list[str] = []
|
|
85
|
+
for param_name, spec in self.parameters.items():
|
|
86
|
+
props[param_name] = {
|
|
87
|
+
"type": _py_to_json_type(spec.get("type", "str")),
|
|
88
|
+
"description": spec.get("description", ""),
|
|
89
|
+
}
|
|
90
|
+
if spec.get("required", True):
|
|
91
|
+
required.append(param_name)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"name": self.name,
|
|
95
|
+
"description": self.description,
|
|
96
|
+
"parameters": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": props,
|
|
99
|
+
"required": required,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"{self.__class__.__name__}(name={self.name!r})"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _py_to_json_type(py_type: str) -> str:
|
|
108
|
+
mapping = {
|
|
109
|
+
"str": "string",
|
|
110
|
+
"int": "integer",
|
|
111
|
+
"float": "number",
|
|
112
|
+
"bool": "boolean",
|
|
113
|
+
"list": "array",
|
|
114
|
+
"dict": "object",
|
|
115
|
+
}
|
|
116
|
+
return mapping.get(py_type, "string")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cortexflow-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed interfaces for building CortexFlow plugins, tools, and channel adapters — no gateway dependencies required.
|
|
5
|
+
Author-email: Amit Chandra <amit.vervebot@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.2.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# cortexflow-sdk
|
|
16
|
+
|
|
17
|
+
Typed interfaces for building [CortexFlow](https://github.com/TheAmitChandra/CortexFlow) plugins — without installing the full gateway.
|
|
18
|
+
|
|
19
|
+
CortexFlow's gateway depends on FastAPI, Redis, Qdrant, and SDKs for all 14
|
|
20
|
+
supported channels. None of that is needed to *write* a plugin — only to
|
|
21
|
+
*run* the gateway that loads it. This package contains just the three base
|
|
22
|
+
classes a plugin author needs, with zero third-party dependencies.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install cortexflow-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Writing a plugin
|
|
31
|
+
|
|
32
|
+
Every plugin package registers a `Plugin` subclass via a `cortexflow.plugins`
|
|
33
|
+
entry point:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# my_plugin/plugin.py
|
|
37
|
+
from cortexflow_sdk import Plugin, PluginMetadata, Tool, ToolResult
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WeatherTool(Tool):
|
|
41
|
+
name = "get_weather"
|
|
42
|
+
description = "Get the current weather for a city."
|
|
43
|
+
parameters = {
|
|
44
|
+
"city": {"type": "str", "description": "City name", "required": True},
|
|
45
|
+
}
|
|
46
|
+
permissions = ["network"]
|
|
47
|
+
|
|
48
|
+
async def execute(self, city: str) -> ToolResult:
|
|
49
|
+
# ... call a weather API ...
|
|
50
|
+
return ToolResult(tool=self.name, output=f"Sunny in {city}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WeatherPlugin(Plugin):
|
|
54
|
+
metadata = PluginMetadata(
|
|
55
|
+
name="cortexflow-weather",
|
|
56
|
+
version="1.0.0",
|
|
57
|
+
plugin_type="tool",
|
|
58
|
+
description="Adds a get_weather tool.",
|
|
59
|
+
permissions=["network"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def get_tools(self):
|
|
63
|
+
return [WeatherTool()]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```toml
|
|
67
|
+
# my_plugin/pyproject.toml
|
|
68
|
+
[project.entry-points."cortexflow.plugins"]
|
|
69
|
+
cortexflow-weather = "my_plugin.plugin:WeatherPlugin"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Once published to PyPI and installed alongside the CortexFlow gateway,
|
|
73
|
+
`cortex plugin add cortexflow-weather` discovers and loads it.
|
|
74
|
+
|
|
75
|
+
## Plugin types
|
|
76
|
+
|
|
77
|
+
| `plugin_type` | Implement | Contributes |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `tool` | `get_tools()` | One or more `Tool` instances |
|
|
80
|
+
| `channel` | `get_channel_adapter()` | A `ChannelAdapter` instance |
|
|
81
|
+
| `tts` / `stt` / `memory` | — | Loaded by name; see gateway docs |
|
|
82
|
+
| `generic` | `on_load()` / `on_unload()` | Lifecycle hooks only |
|
|
83
|
+
|
|
84
|
+
## Writing a channel adapter plugin
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from cortexflow_sdk import ChannelAdapter, InboundMessage
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MyChannelAdapter(ChannelAdapter):
|
|
91
|
+
channel_id = "my_channel"
|
|
92
|
+
|
|
93
|
+
async def connect(self) -> None: ...
|
|
94
|
+
async def disconnect(self) -> None: ...
|
|
95
|
+
async def send(self, target, text, *, reply_to=None, attachments=None):
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
async def _on_platform_event(self, raw_event: dict) -> None:
|
|
99
|
+
await self._dispatch(InboundMessage(
|
|
100
|
+
channel=self.channel_id,
|
|
101
|
+
sender_id=raw_event["user_id"],
|
|
102
|
+
sender_name=raw_event["user_name"],
|
|
103
|
+
text=raw_event["text"],
|
|
104
|
+
))
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/cortexflow_sdk/__init__.py
|
|
5
|
+
src/cortexflow_sdk/channels.py
|
|
6
|
+
src/cortexflow_sdk/plugins.py
|
|
7
|
+
src/cortexflow_sdk/tools.py
|
|
8
|
+
src/cortexflow_sdk.egg-info/PKG-INFO
|
|
9
|
+
src/cortexflow_sdk.egg-info/SOURCES.txt
|
|
10
|
+
src/cortexflow_sdk.egg-info/dependency_links.txt
|
|
11
|
+
src/cortexflow_sdk.egg-info/requires.txt
|
|
12
|
+
src/cortexflow_sdk.egg-info/top_level.txt
|
|
13
|
+
tests/test_channels.py
|
|
14
|
+
tests/test_plugins.py
|
|
15
|
+
tests/test_tools.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cortexflow_sdk
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Unit tests for cortexflow_sdk.channels — ChannelAdapter / InboundMessage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from cortexflow_sdk import Attachment, ChannelAdapter, InboundMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_inbound_message_defaults():
|
|
10
|
+
msg = InboundMessage(channel="x", sender_id="u1", sender_name="Alice", text="hi")
|
|
11
|
+
assert msg.attachments == []
|
|
12
|
+
assert msg.thread_id is None
|
|
13
|
+
assert msg.raw == {}
|
|
14
|
+
assert msg.timestamp > 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_attachment_defaults():
|
|
18
|
+
att = Attachment(type="image")
|
|
19
|
+
assert att.url is None
|
|
20
|
+
assert att.data is None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _EchoAdapter(ChannelAdapter):
|
|
24
|
+
channel_id = "echo"
|
|
25
|
+
|
|
26
|
+
def __init__(self, config):
|
|
27
|
+
super().__init__(config)
|
|
28
|
+
self.sent: list[tuple[str, str]] = []
|
|
29
|
+
self.connected = False
|
|
30
|
+
|
|
31
|
+
async def connect(self) -> None:
|
|
32
|
+
self.connected = True
|
|
33
|
+
|
|
34
|
+
async def disconnect(self) -> None:
|
|
35
|
+
self.connected = False
|
|
36
|
+
|
|
37
|
+
async def send(self, target, text, *, reply_to=None, attachments=None):
|
|
38
|
+
self.sent.append((target, text))
|
|
39
|
+
return "msg-1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_subclassed_adapter_connect_disconnect():
|
|
44
|
+
adapter = _EchoAdapter({"enabled": True})
|
|
45
|
+
await adapter.connect()
|
|
46
|
+
assert adapter.connected is True
|
|
47
|
+
await adapter.disconnect()
|
|
48
|
+
assert adapter.connected is False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_subclassed_adapter_send_returns_message_id():
|
|
53
|
+
adapter = _EchoAdapter({})
|
|
54
|
+
result = await adapter.send("user-1", "hello")
|
|
55
|
+
assert result == "msg-1"
|
|
56
|
+
assert adapter.sent == [("user-1", "hello")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_on_message_handler_receives_dispatched_message():
|
|
61
|
+
adapter = _EchoAdapter({})
|
|
62
|
+
received: list[InboundMessage] = []
|
|
63
|
+
|
|
64
|
+
async def handler(msg: InboundMessage) -> None:
|
|
65
|
+
received.append(msg)
|
|
66
|
+
|
|
67
|
+
adapter.on_message(handler)
|
|
68
|
+
msg = InboundMessage(channel="echo", sender_id="u1", sender_name="Bob", text="hi")
|
|
69
|
+
await adapter._dispatch(msg)
|
|
70
|
+
|
|
71
|
+
assert received == [msg]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_dispatch_without_handler_is_noop():
|
|
76
|
+
adapter = _EchoAdapter({})
|
|
77
|
+
msg = InboundMessage(channel="echo", sender_id="u1", sender_name="Bob", text="hi")
|
|
78
|
+
await adapter._dispatch(msg) # must not raise
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_default_config_schema():
|
|
82
|
+
schema = _EchoAdapter({}).get_config_schema()
|
|
83
|
+
assert schema["properties"]["enabled"]["default"] is False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_adapter_repr_includes_channel_id():
|
|
87
|
+
assert repr(_EchoAdapter({})) == "_EchoAdapter(channel_id='echo')"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_channel_adapter_is_abstract():
|
|
91
|
+
with pytest.raises(TypeError):
|
|
92
|
+
ChannelAdapter({}) # type: ignore[abstract]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Unit tests for cortexflow_sdk.plugins — Plugin / PluginMetadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from cortexflow_sdk import ChannelAdapter, Plugin, PluginMetadata, Tool, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_plugin_metadata_defaults():
|
|
10
|
+
meta = PluginMetadata(name="x", version="1.0", plugin_type="generic", description="d")
|
|
11
|
+
assert meta.permissions == []
|
|
12
|
+
assert meta.author == ""
|
|
13
|
+
assert meta.homepage == ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _NoopPlugin(Plugin):
|
|
17
|
+
metadata = PluginMetadata(
|
|
18
|
+
name="noop", version="0.1", plugin_type="generic", description="Does nothing.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_default_lifecycle_hooks_are_noops():
|
|
24
|
+
p = _NoopPlugin()
|
|
25
|
+
await p.on_load()
|
|
26
|
+
await p.on_unload() # neither should raise
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_default_get_tools_returns_empty_list():
|
|
30
|
+
assert _NoopPlugin().get_tools() == []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_default_get_channel_adapter_returns_none():
|
|
34
|
+
assert _NoopPlugin().get_channel_adapter() is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_default_get_config_schema():
|
|
38
|
+
assert _NoopPlugin().get_config_schema() == {"type": "object", "properties": {}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_plugin_repr():
|
|
42
|
+
assert repr(_NoopPlugin()) == "Plugin('noop' v0.1, type='generic')"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _GreetTool(Tool):
|
|
46
|
+
name = "greet"
|
|
47
|
+
description = "Greets someone."
|
|
48
|
+
parameters = {"name": {"type": "str", "description": "Name", "required": True}}
|
|
49
|
+
|
|
50
|
+
async def execute(self, name: str) -> ToolResult:
|
|
51
|
+
return ToolResult(tool=self.name, output=f"Hello, {name}!")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _ToolPlugin(Plugin):
|
|
55
|
+
metadata = PluginMetadata(
|
|
56
|
+
name="greeter", version="1.0", plugin_type="tool", description="Adds a greet tool.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def get_tools(self):
|
|
60
|
+
return [_GreetTool()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_tool_plugin_contributes_tool():
|
|
64
|
+
tools = _ToolPlugin().get_tools()
|
|
65
|
+
assert len(tools) == 1
|
|
66
|
+
assert tools[0].name == "greet"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _StubChannelAdapter(ChannelAdapter):
|
|
70
|
+
channel_id = "stub"
|
|
71
|
+
|
|
72
|
+
async def connect(self) -> None: ...
|
|
73
|
+
async def disconnect(self) -> None: ...
|
|
74
|
+
async def send(self, target, text, *, reply_to=None, attachments=None):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _ChannelPlugin(Plugin):
|
|
79
|
+
metadata = PluginMetadata(
|
|
80
|
+
name="stub-channel", version="1.0", plugin_type="channel", description="Adds a stub channel.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def get_channel_adapter(self):
|
|
84
|
+
return _StubChannelAdapter({})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_channel_plugin_contributes_adapter():
|
|
88
|
+
adapter = _ChannelPlugin().get_channel_adapter()
|
|
89
|
+
assert adapter is not None
|
|
90
|
+
assert adapter.channel_id == "stub"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Unit tests for cortexflow_sdk.tools — Tool / ToolResult."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from cortexflow_sdk import Tool, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_tool_result_success_when_no_error():
|
|
10
|
+
r = ToolResult(tool="t", output="ok")
|
|
11
|
+
assert r.success is True
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_tool_result_failure_when_error_set():
|
|
15
|
+
r = ToolResult(tool="t", output=None, error="boom")
|
|
16
|
+
assert r.success is False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_to_prompt_block_success():
|
|
20
|
+
r = ToolResult(tool="echo", output="hello")
|
|
21
|
+
assert r.to_prompt_block() == "[TOOL:echo]\nhello"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_to_prompt_block_error():
|
|
25
|
+
r = ToolResult(tool="echo", output=None, error="failed")
|
|
26
|
+
assert r.to_prompt_block() == "[TOOL:echo ERROR] failed"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_to_prompt_block_stringifies_non_string_output():
|
|
30
|
+
r = ToolResult(tool="calc", output=42)
|
|
31
|
+
assert r.to_prompt_block() == "[TOOL:calc]\n42"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _EchoTool(Tool):
|
|
35
|
+
name = "echo"
|
|
36
|
+
description = "Echoes the input."
|
|
37
|
+
parameters = {
|
|
38
|
+
"text": {"type": "str", "description": "Text to echo", "required": True},
|
|
39
|
+
"loud": {"type": "bool", "description": "Shout it", "required": False},
|
|
40
|
+
}
|
|
41
|
+
permissions = ["network"]
|
|
42
|
+
|
|
43
|
+
async def execute(self, text: str, loud: bool = False) -> ToolResult:
|
|
44
|
+
return ToolResult(tool=self.name, output=text.upper() if loud else text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_subclassed_tool_executes():
|
|
49
|
+
tool = _EchoTool()
|
|
50
|
+
result = await tool.execute(text="hi", loud=True)
|
|
51
|
+
assert result.output == "HI"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_get_schema_marks_required_params():
|
|
55
|
+
schema = _EchoTool().get_schema()
|
|
56
|
+
assert schema["name"] == "echo"
|
|
57
|
+
assert "text" in schema["parameters"]["required"]
|
|
58
|
+
assert "loud" not in schema["parameters"]["required"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_get_schema_maps_python_types_to_json_types():
|
|
62
|
+
schema = _EchoTool().get_schema()
|
|
63
|
+
assert schema["parameters"]["properties"]["loud"]["type"] == "boolean"
|
|
64
|
+
assert schema["parameters"]["properties"]["text"]["type"] == "string"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_tool_repr_includes_name():
|
|
68
|
+
assert repr(_EchoTool()) == "_EchoTool(name='echo')"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_tool_is_abstract():
|
|
72
|
+
with pytest.raises(TypeError):
|
|
73
|
+
Tool() # type: ignore[abstract]
|