arcade-serve 2.0.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.
- arcade_serve-2.0.0/.gitignore +175 -0
- arcade_serve-2.0.0/PKG-INFO +97 -0
- arcade_serve-2.0.0/README.md +70 -0
- arcade_serve-2.0.0/arcade_serve/__init__.py +0 -0
- arcade_serve-2.0.0/arcade_serve/core/__init__.py +0 -0
- arcade_serve-2.0.0/arcade_serve/core/auth.py +45 -0
- arcade_serve-2.0.0/arcade_serve/core/base.py +185 -0
- arcade_serve-2.0.0/arcade_serve/core/common.py +93 -0
- arcade_serve-2.0.0/arcade_serve/core/components.py +99 -0
- arcade_serve-2.0.0/arcade_serve/fastapi/__init__.py +3 -0
- arcade_serve-2.0.0/arcade_serve/fastapi/auth.py +20 -0
- arcade_serve-2.0.0/arcade_serve/fastapi/worker.py +111 -0
- arcade_serve-2.0.0/arcade_serve/mcp/__init__.py +7 -0
- arcade_serve-2.0.0/arcade_serve/mcp/convert.py +188 -0
- arcade_serve-2.0.0/arcade_serve/mcp/logging.py +215 -0
- arcade_serve-2.0.0/arcade_serve/mcp/message_processor.py +83 -0
- arcade_serve-2.0.0/arcade_serve/mcp/server.py +601 -0
- arcade_serve-2.0.0/arcade_serve/mcp/stdio.py +185 -0
- arcade_serve-2.0.0/arcade_serve/mcp/types.py +383 -0
- arcade_serve-2.0.0/arcade_serve/py.typed +0 -0
- arcade_serve-2.0.0/arcade_serve/utils.py +8 -0
- arcade_serve-2.0.0/pyproject.toml +54 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
credentials.yaml
|
|
3
|
+
docker/credentials.yaml
|
|
4
|
+
|
|
5
|
+
*.lock
|
|
6
|
+
|
|
7
|
+
# example data
|
|
8
|
+
examples/data
|
|
9
|
+
scratch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
docs/source
|
|
13
|
+
|
|
14
|
+
# From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
|
|
15
|
+
|
|
16
|
+
# Byte-compiled / optimized / DLL files
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.py[cod]
|
|
19
|
+
*$py.class
|
|
20
|
+
|
|
21
|
+
# C extensions
|
|
22
|
+
*.so
|
|
23
|
+
|
|
24
|
+
# Distribution / packaging
|
|
25
|
+
.Python
|
|
26
|
+
build/
|
|
27
|
+
develop-eggs/
|
|
28
|
+
dist/
|
|
29
|
+
downloads/
|
|
30
|
+
eggs/
|
|
31
|
+
.eggs/
|
|
32
|
+
lib/
|
|
33
|
+
lib64/
|
|
34
|
+
parts/
|
|
35
|
+
sdist/
|
|
36
|
+
var/
|
|
37
|
+
wheels/
|
|
38
|
+
share/python-wheels/
|
|
39
|
+
*.egg-info/
|
|
40
|
+
.installed.cfg
|
|
41
|
+
*.egg
|
|
42
|
+
MANIFEST
|
|
43
|
+
|
|
44
|
+
# PyInstaller
|
|
45
|
+
# Usually these files are written by a python script from a template
|
|
46
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
47
|
+
*.manifest
|
|
48
|
+
*.spec
|
|
49
|
+
|
|
50
|
+
# Installer logs
|
|
51
|
+
pip-log.txt
|
|
52
|
+
pip-delete-this-directory.txt
|
|
53
|
+
|
|
54
|
+
# Unit test / coverage reports
|
|
55
|
+
htmlcov/
|
|
56
|
+
.tox/
|
|
57
|
+
.nox/
|
|
58
|
+
.coverage
|
|
59
|
+
.coverage.*
|
|
60
|
+
.cache
|
|
61
|
+
nosetests.xml
|
|
62
|
+
coverage.xml
|
|
63
|
+
*.cover
|
|
64
|
+
*.py,cover
|
|
65
|
+
.hypothesis/
|
|
66
|
+
.pytest_cache/
|
|
67
|
+
cover/
|
|
68
|
+
|
|
69
|
+
# Translations
|
|
70
|
+
*.mo
|
|
71
|
+
*.pot
|
|
72
|
+
|
|
73
|
+
# Django stuff:
|
|
74
|
+
*.log
|
|
75
|
+
local_settings.py
|
|
76
|
+
db.sqlite3
|
|
77
|
+
db.sqlite3-journal
|
|
78
|
+
|
|
79
|
+
# Flask stuff:
|
|
80
|
+
instance/
|
|
81
|
+
.webassets-cache
|
|
82
|
+
|
|
83
|
+
# Scrapy stuff:
|
|
84
|
+
.scrapy
|
|
85
|
+
|
|
86
|
+
# Sphinx documentation
|
|
87
|
+
docs/_build/
|
|
88
|
+
|
|
89
|
+
# PyBuilder
|
|
90
|
+
.pybuilder/
|
|
91
|
+
target/
|
|
92
|
+
|
|
93
|
+
# Jupyter Notebook
|
|
94
|
+
.ipynb_checkpoints
|
|
95
|
+
|
|
96
|
+
# IPython
|
|
97
|
+
profile_default/
|
|
98
|
+
ipython_config.py
|
|
99
|
+
|
|
100
|
+
# pyenv
|
|
101
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
102
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
103
|
+
# .python-version
|
|
104
|
+
|
|
105
|
+
# pipenv
|
|
106
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
107
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
108
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
109
|
+
# install all needed dependencies.
|
|
110
|
+
#Pipfile.lock
|
|
111
|
+
|
|
112
|
+
# poetry
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
114
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
115
|
+
# commonly ignored for libraries.
|
|
116
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
117
|
+
poetry.lock
|
|
118
|
+
|
|
119
|
+
# pdm
|
|
120
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
121
|
+
#pdm.lock
|
|
122
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
123
|
+
# in version control.
|
|
124
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
125
|
+
.pdm.toml
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.venv
|
|
140
|
+
env/
|
|
141
|
+
venv/
|
|
142
|
+
ENV/
|
|
143
|
+
env.bak/
|
|
144
|
+
venv.bak/
|
|
145
|
+
|
|
146
|
+
# Spyder project settings
|
|
147
|
+
.spyderproject
|
|
148
|
+
.spyproject
|
|
149
|
+
|
|
150
|
+
# Rope project settings
|
|
151
|
+
.ropeproject
|
|
152
|
+
|
|
153
|
+
# mkdocs documentation
|
|
154
|
+
/site
|
|
155
|
+
|
|
156
|
+
# mypy
|
|
157
|
+
.mypy_cache/
|
|
158
|
+
.dmypy.json
|
|
159
|
+
dmypy.json
|
|
160
|
+
|
|
161
|
+
# Pyre type checker
|
|
162
|
+
.pyre/
|
|
163
|
+
|
|
164
|
+
# pytype static type analyzer
|
|
165
|
+
.pytype/
|
|
166
|
+
|
|
167
|
+
# Cython debug symbols
|
|
168
|
+
cython_debug/
|
|
169
|
+
|
|
170
|
+
# PyCharm
|
|
171
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
172
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
173
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
174
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
175
|
+
#.idea/
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arcade-serve
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Arcade Serve - Serving infrastructure for Arcade tools and workers
|
|
5
|
+
Author-email: Arcade <dev@arcade.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: arcade-core<3.0.0,>=2.0.0
|
|
17
|
+
Requires-Dist: fastapi>=0.115.3
|
|
18
|
+
Requires-Dist: uvicorn>=0.30.0
|
|
19
|
+
Requires-Dist: watchfiles>=1.0.5
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.5.1; extra == 'dev'
|
|
22
|
+
Requires-Dist: pre-commit>=3.4.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23.7; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.1.2; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Arcade Serve
|
|
29
|
+
|
|
30
|
+
Serving infrastructure for Arcade tools and workers.
|
|
31
|
+
|
|
32
|
+
## Overview
|
|
33
|
+
|
|
34
|
+
Arcade Serve provides the infrastructure for serving Arcade tools:
|
|
35
|
+
|
|
36
|
+
- **FastAPI Worker**: High-performance FastAPI-based worker implementation
|
|
37
|
+
- **MCP Server**: Model Context Protocol server for tool integration
|
|
38
|
+
- **Core Abstractions**: Base worker classes and components
|
|
39
|
+
- **Authentication**: Auth utilities and routing
|
|
40
|
+
- **Runtime Management**: Tool execution and lifecycle management
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install arcade-serve
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
To add a toolkit to a hosted worker such as FastAPI, you can register them in the worker itself.
|
|
51
|
+
This allows you to explicitly define which tools should be included on a particular worker.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
Here is an example of adding the math toolkit (pip install arcade-math) to a FastAPI Worker:
|
|
55
|
+
```python
|
|
56
|
+
import arcade_math
|
|
57
|
+
from fastapi import FastAPI
|
|
58
|
+
from arcade_tdk import Toolkit
|
|
59
|
+
from arcade_serve.fastapi import FastAPIWorker
|
|
60
|
+
|
|
61
|
+
app = FastAPI()
|
|
62
|
+
|
|
63
|
+
worker_secret = os.environ.get("ARCADE_WORKER_SECRET")
|
|
64
|
+
worker = FastAPIWorker(app, secret=worker_secret)
|
|
65
|
+
|
|
66
|
+
worker.register_toolkit(Toolkit.from_module(arcade_math))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Here is an example of adding the math toolkit (pip install arcade-math) to a MCP Worker
|
|
70
|
+
```python
|
|
71
|
+
import arcade_math
|
|
72
|
+
from arcade_core.catalog import ToolCatalog
|
|
73
|
+
from arcade_serve.mcp.stdio import StdioServer
|
|
74
|
+
|
|
75
|
+
# 1. Create and populate the tool catalog
|
|
76
|
+
catalog = ToolCatalog()
|
|
77
|
+
catalog.add_module(arcade_math)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# 2. Main entrypoint
|
|
81
|
+
async def main():
|
|
82
|
+
# Create the worker with the tool catalog
|
|
83
|
+
worker = StdioServer(catalog)
|
|
84
|
+
|
|
85
|
+
# Run the worker
|
|
86
|
+
await worker.run()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
import asyncio
|
|
91
|
+
|
|
92
|
+
asyncio.run(main())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Arcade Serve
|
|
2
|
+
|
|
3
|
+
Serving infrastructure for Arcade tools and workers.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Arcade Serve provides the infrastructure for serving Arcade tools:
|
|
8
|
+
|
|
9
|
+
- **FastAPI Worker**: High-performance FastAPI-based worker implementation
|
|
10
|
+
- **MCP Server**: Model Context Protocol server for tool integration
|
|
11
|
+
- **Core Abstractions**: Base worker classes and components
|
|
12
|
+
- **Authentication**: Auth utilities and routing
|
|
13
|
+
- **Runtime Management**: Tool execution and lifecycle management
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install arcade-serve
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
To add a toolkit to a hosted worker such as FastAPI, you can register them in the worker itself.
|
|
24
|
+
This allows you to explicitly define which tools should be included on a particular worker.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Here is an example of adding the math toolkit (pip install arcade-math) to a FastAPI Worker:
|
|
28
|
+
```python
|
|
29
|
+
import arcade_math
|
|
30
|
+
from fastapi import FastAPI
|
|
31
|
+
from arcade_tdk import Toolkit
|
|
32
|
+
from arcade_serve.fastapi import FastAPIWorker
|
|
33
|
+
|
|
34
|
+
app = FastAPI()
|
|
35
|
+
|
|
36
|
+
worker_secret = os.environ.get("ARCADE_WORKER_SECRET")
|
|
37
|
+
worker = FastAPIWorker(app, secret=worker_secret)
|
|
38
|
+
|
|
39
|
+
worker.register_toolkit(Toolkit.from_module(arcade_math))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Here is an example of adding the math toolkit (pip install arcade-math) to a MCP Worker
|
|
43
|
+
```python
|
|
44
|
+
import arcade_math
|
|
45
|
+
from arcade_core.catalog import ToolCatalog
|
|
46
|
+
from arcade_serve.mcp.stdio import StdioServer
|
|
47
|
+
|
|
48
|
+
# 1. Create and populate the tool catalog
|
|
49
|
+
catalog = ToolCatalog()
|
|
50
|
+
catalog.add_module(arcade_math)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# 2. Main entrypoint
|
|
54
|
+
async def main():
|
|
55
|
+
# Create the worker with the tool catalog
|
|
56
|
+
worker = StdioServer(catalog)
|
|
57
|
+
|
|
58
|
+
# Run the worker
|
|
59
|
+
await worker.run()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
import asyncio
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT License - see LICENSE file for details.
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
|
|
7
|
+
SUPPORTED_TOKEN_VER = "1" # noqa: S105 Possible hardcoded password assigned (false positive)
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TokenValidationResult:
|
|
14
|
+
valid: bool
|
|
15
|
+
error: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SigningAlgorithm(str, Enum):
|
|
19
|
+
HS256 = "HS256"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_engine_token(worker_secret: str, token: str) -> TokenValidationResult:
|
|
23
|
+
try:
|
|
24
|
+
payload = jwt.decode(
|
|
25
|
+
token,
|
|
26
|
+
worker_secret,
|
|
27
|
+
algorithms=[SigningAlgorithm.HS256],
|
|
28
|
+
verify=True,
|
|
29
|
+
audience="worker",
|
|
30
|
+
)
|
|
31
|
+
except jwt.InvalidSignatureError as e:
|
|
32
|
+
logger.warning(
|
|
33
|
+
"Invalid signature. Is the Arcade Engine configured with the Worker secret '%s'?",
|
|
34
|
+
worker_secret,
|
|
35
|
+
)
|
|
36
|
+
return TokenValidationResult(valid=False, error=str(e))
|
|
37
|
+
|
|
38
|
+
except jwt.InvalidTokenError as e:
|
|
39
|
+
return TokenValidationResult(valid=False, error=str(e))
|
|
40
|
+
|
|
41
|
+
token_ver = payload.get("ver")
|
|
42
|
+
if token_ver != SUPPORTED_TOKEN_VER:
|
|
43
|
+
return TokenValidationResult(valid=False, error=f"Unsupported token version: {token_ver}")
|
|
44
|
+
|
|
45
|
+
return TokenValidationResult(valid=True)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Callable, ClassVar
|
|
6
|
+
|
|
7
|
+
from arcade_core.catalog import ToolCatalog, Toolkit
|
|
8
|
+
from arcade_core.executor import ToolExecutor
|
|
9
|
+
from arcade_core.schema import (
|
|
10
|
+
ToolCallRequest,
|
|
11
|
+
ToolCallResponse,
|
|
12
|
+
ToolDefinition,
|
|
13
|
+
)
|
|
14
|
+
from opentelemetry import trace
|
|
15
|
+
from opentelemetry.metrics import Meter
|
|
16
|
+
|
|
17
|
+
from arcade_serve.core.common import Router, Worker
|
|
18
|
+
from arcade_serve.core.components import (
|
|
19
|
+
CallToolComponent,
|
|
20
|
+
CatalogComponent,
|
|
21
|
+
HealthCheckComponent,
|
|
22
|
+
WorkerComponent,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BaseWorker(Worker):
|
|
29
|
+
"""
|
|
30
|
+
A base worker class that provides a default implementation for registering tools and invoking them.
|
|
31
|
+
Worker implementations for specific web frameworks will inherit from this class.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
base_path = "/worker" # By default, prefix all our routes with /worker
|
|
35
|
+
|
|
36
|
+
default_components: ClassVar[tuple[type[WorkerComponent], ...]] = (
|
|
37
|
+
CatalogComponent,
|
|
38
|
+
CallToolComponent,
|
|
39
|
+
HealthCheckComponent,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self, secret: str | None = None, disable_auth: bool = False, otel_meter: Meter | None = None
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Initialize the BaseWorker with an empty ToolCatalog.
|
|
47
|
+
If no secret is provided, the worker will use the ARCADE_WORKER_SECRET environment variable.
|
|
48
|
+
"""
|
|
49
|
+
self.catalog = ToolCatalog()
|
|
50
|
+
self.disable_auth = disable_auth
|
|
51
|
+
if disable_auth:
|
|
52
|
+
logger.warning(
|
|
53
|
+
"Warning: Worker is running without authentication. Not recommended for production."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self.secret = self._set_secret(secret, disable_auth)
|
|
57
|
+
self.environment = os.environ.get("ARCADE_ENVIRONMENT", "local")
|
|
58
|
+
|
|
59
|
+
self.tool_counter = None
|
|
60
|
+
if otel_meter:
|
|
61
|
+
self.tool_counter = otel_meter.create_counter(
|
|
62
|
+
"tool_call", "requests", "Total number of tools called"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _set_secret(self, secret: str | None, disable_auth: bool) -> str:
|
|
66
|
+
if disable_auth:
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
# If secret is provided, use it
|
|
70
|
+
if secret:
|
|
71
|
+
return secret
|
|
72
|
+
|
|
73
|
+
# If secret is not provided, try to get it from environment variables
|
|
74
|
+
env_secret = os.environ.get("ARCADE_WORKER_SECRET")
|
|
75
|
+
if env_secret:
|
|
76
|
+
return env_secret
|
|
77
|
+
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"No secret provided for worker. Set the ARCADE_WORKER_SECRET environment variable."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def get_catalog(self) -> list[ToolDefinition]:
|
|
83
|
+
"""
|
|
84
|
+
Get the catalog as a list of ToolDefinitions.
|
|
85
|
+
"""
|
|
86
|
+
return [tool.definition for tool in self.catalog]
|
|
87
|
+
|
|
88
|
+
def register_tool(self, tool: Callable, toolkit_name: str) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Register a tool to the catalog.
|
|
91
|
+
"""
|
|
92
|
+
self.catalog.add_tool(tool, toolkit_name)
|
|
93
|
+
|
|
94
|
+
def register_toolkit(self, toolkit: Toolkit) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Register a toolkit to the catalog.
|
|
97
|
+
"""
|
|
98
|
+
self.catalog.add_toolkit(toolkit)
|
|
99
|
+
|
|
100
|
+
async def call_tool(self, tool_request: ToolCallRequest) -> ToolCallResponse:
|
|
101
|
+
"""
|
|
102
|
+
Call (invoke) a tool using the ToolExecutor.
|
|
103
|
+
"""
|
|
104
|
+
tool_fqname = tool_request.tool.get_fully_qualified_name()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
materialized_tool = self.catalog.get_tool(tool_fqname)
|
|
108
|
+
except KeyError:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Tool {tool_fqname} not found in catalog with toolkit version {tool_request.tool.version}."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
start_time = time.time()
|
|
114
|
+
|
|
115
|
+
if self.tool_counter:
|
|
116
|
+
self.tool_counter.add(
|
|
117
|
+
1,
|
|
118
|
+
{
|
|
119
|
+
"tool_name": tool_fqname.name,
|
|
120
|
+
"toolkit_version": str(tool_fqname.toolkit_version),
|
|
121
|
+
"toolkit_name": tool_fqname.toolkit_name,
|
|
122
|
+
"environment": self.environment,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
execution_id = tool_request.execution_id or ""
|
|
126
|
+
logger.info(
|
|
127
|
+
f"{execution_id} | Calling tool: {tool_fqname} version: {tool_request.tool.version}"
|
|
128
|
+
)
|
|
129
|
+
logger.debug(f"{execution_id} | Tool inputs: {tool_request.inputs}")
|
|
130
|
+
|
|
131
|
+
tracer = trace.get_tracer(__name__)
|
|
132
|
+
with tracer.start_as_current_span("RunTool"):
|
|
133
|
+
output = await ToolExecutor.run(
|
|
134
|
+
func=materialized_tool.tool,
|
|
135
|
+
definition=materialized_tool.definition,
|
|
136
|
+
input_model=materialized_tool.input_model,
|
|
137
|
+
output_model=materialized_tool.output_model,
|
|
138
|
+
context=tool_request.context,
|
|
139
|
+
**tool_request.inputs or {},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
end_time = time.time() # End time in seconds
|
|
143
|
+
duration_ms = (end_time - start_time) * 1000 # Convert to milliseconds
|
|
144
|
+
|
|
145
|
+
if output.error:
|
|
146
|
+
logger.warning(
|
|
147
|
+
f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} failed"
|
|
148
|
+
)
|
|
149
|
+
logger.warning(f"{execution_id} | Tool error: {output.error.message}")
|
|
150
|
+
logger.warning(
|
|
151
|
+
f"{execution_id} | Tool developer message: {output.error.developer_message}"
|
|
152
|
+
)
|
|
153
|
+
logger.debug(
|
|
154
|
+
f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
|
|
155
|
+
)
|
|
156
|
+
if output.error.traceback_info:
|
|
157
|
+
logger.debug(f"{execution_id} | Tool traceback: {output.error.traceback_info}")
|
|
158
|
+
else:
|
|
159
|
+
logger.info(
|
|
160
|
+
f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} success"
|
|
161
|
+
)
|
|
162
|
+
logger.debug(
|
|
163
|
+
f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return ToolCallResponse(
|
|
167
|
+
execution_id=execution_id,
|
|
168
|
+
duration=duration_ms,
|
|
169
|
+
finished_at=datetime.now().isoformat(),
|
|
170
|
+
success=not output.error,
|
|
171
|
+
output=output,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def health_check(self) -> dict[str, Any]:
|
|
175
|
+
"""
|
|
176
|
+
Provide a health check that serves as a heartbeat of worker health.
|
|
177
|
+
"""
|
|
178
|
+
return {"status": "ok", "tool_count": str(len(self.catalog))}
|
|
179
|
+
|
|
180
|
+
def register_routes(self, router: Router) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Register the necessary routes to the application.
|
|
183
|
+
"""
|
|
184
|
+
for component_cls in self.default_components:
|
|
185
|
+
component_cls(self).register(router)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
from arcade_core.schema import ToolCallRequest, ToolCallResponse, ToolDefinition
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
CatalogResponse = list[ToolDefinition]
|
|
8
|
+
HealthCheckResponse = dict[str, str]
|
|
9
|
+
JSONResponse = dict[str, Any]
|
|
10
|
+
ResponseData = CatalogResponse | ToolCallResponse | HealthCheckResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RequestData(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
The raw data for a request to a worker.
|
|
16
|
+
This is not intended to represent everything about an HTTP request,
|
|
17
|
+
but just the essential info a framework integration will need to extract from the request.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
path: str
|
|
21
|
+
"""The path of the request."""
|
|
22
|
+
method: str
|
|
23
|
+
"""The method of the request."""
|
|
24
|
+
body_json: JSONResponse | None = None
|
|
25
|
+
"""The deserialized body of the request (e.g. JSON)"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Router(ABC):
|
|
29
|
+
"""
|
|
30
|
+
A router is responsible for adding routes to the underlying framework hosting the worker.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def add_route(
|
|
35
|
+
self,
|
|
36
|
+
endpoint_path: str,
|
|
37
|
+
handler: Callable,
|
|
38
|
+
method: str,
|
|
39
|
+
require_auth: bool = True,
|
|
40
|
+
response_type: type[ResponseData] | None = None,
|
|
41
|
+
**kwargs: Any,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Add a route to the router.
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Worker(ABC):
|
|
50
|
+
"""
|
|
51
|
+
A Worker represents a collection of tools that is hosted inside a web framework
|
|
52
|
+
and can be called by an Engine.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def get_catalog(self) -> CatalogResponse:
|
|
57
|
+
"""
|
|
58
|
+
Get the catalog of tools available in the worker.
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def call_tool(self, request: ToolCallRequest) -> ToolCallResponse:
|
|
64
|
+
"""
|
|
65
|
+
Send a request to call a tool to the Worker
|
|
66
|
+
"""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def health_check(self) -> HealthCheckResponse:
|
|
71
|
+
"""
|
|
72
|
+
Perform a health check of the worker
|
|
73
|
+
"""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class WorkerComponent(ABC):
|
|
78
|
+
def __init__(self, worker: Worker) -> None:
|
|
79
|
+
self.worker = worker
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def register(self, router: Router) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Register the component with the given router.
|
|
85
|
+
"""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
async def __call__(self, request: RequestData) -> ResponseData:
|
|
90
|
+
"""
|
|
91
|
+
Handle the request.
|
|
92
|
+
"""
|
|
93
|
+
pass
|