claritty-sdk 2.2.0__tar.gz → 2.5.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.
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/PKG-INFO +1 -1
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/__init__.py +4 -2
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/context.py +15 -0
- claritty_sdk-2.5.0/claritty_sdk/files.py +229 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/catalog.py +2 -2
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/executor_client.py +9 -1
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/platform_creds.py +112 -5
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/platform_client.py +23 -9
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/PKG-INFO +1 -1
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/SOURCES.txt +1 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/pyproject.toml +1 -1
- claritty_sdk-2.5.0/tests/test_integration_broker_dispatch.py +140 -0
- claritty_sdk-2.2.0/tests/test_integration_broker_dispatch.py +0 -60
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/README.md +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/cli_eval.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/cli_verify.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/decorators.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/eval.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/graph.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/__init__.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/__init__.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/airtable.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/anthropic.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/asana.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/calendly.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/clickup.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/client.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/discord.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/dropbox.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/github.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/gitlab.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/gmail.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/google_calendar.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/google_drive.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/hubspot.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/jira.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/linear.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/linkedin.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/mailchimp.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/mcp_client.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/microsoft_teams.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/notion.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/notion_mcp.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/openai.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/outlook.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/outlook_calendar.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/pipedrive.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/postgres.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/posthog.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/s3.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/salesforce.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/slack.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/stripe.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/trello.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/twilio.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/x_twitter.pyi +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/llm.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/manifest.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/__init__.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/bootstrap.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/expressions.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/schema_validate.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/team_runner.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/tool_loop.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/tracing.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/workflow_engine.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/validators.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/dependency_links.txt +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/entry_points.txt +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/requires.txt +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/top_level.txt +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/setup.cfg +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_agent_reasoning.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_execution_end_to_end.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_graph_emitter.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_manifest_doc_parity.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_manifest_triggers.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_parallel_fanin.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_team_mode.py +0 -0
- {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_workflow_engine_failfast.py +0 -0
|
@@ -39,7 +39,7 @@ decorator/registry runtime (``@workflow``/``@uses_agent``/``@trigger_template``,
|
|
|
39
39
|
``WorkflowExecutor``) has been removed — there is one canonical (v2) runtime.
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
|
-
from . import llm
|
|
42
|
+
from . import files, llm
|
|
43
43
|
from .context import AgentContext, AgentResult, BaseAgent, ToolCtx, WorkflowContext
|
|
44
44
|
from .decorators import (
|
|
45
45
|
agent,
|
|
@@ -65,6 +65,8 @@ __all__ = [
|
|
|
65
65
|
"list_registered_tool_ids",
|
|
66
66
|
# llm
|
|
67
67
|
"llm",
|
|
68
|
+
# per-(user, app) file storage
|
|
69
|
+
"files",
|
|
68
70
|
# canvas graph (manifest-driven)
|
|
69
71
|
"build_graph",
|
|
70
72
|
# per-request user context
|
|
@@ -72,4 +74,4 @@ __all__ = [
|
|
|
72
74
|
"use_user_context",
|
|
73
75
|
]
|
|
74
76
|
|
|
75
|
-
__version__ = "2.
|
|
77
|
+
__version__ = "2.3.0"
|
|
@@ -145,6 +145,21 @@ class ToolCtx:
|
|
|
145
145
|
)
|
|
146
146
|
return self._integration_resolver(integration_id)
|
|
147
147
|
|
|
148
|
+
@property
|
|
149
|
+
def files(self) -> Any:
|
|
150
|
+
"""Per-(user, app) file storage bound to this context's ``user_id``.
|
|
151
|
+
|
|
152
|
+
Returns a :class:`claritty_sdk.files.BoundFiles` exposing
|
|
153
|
+
``upload_bytes`` / ``create_upload_url`` / ``list`` / ``download_url`` /
|
|
154
|
+
``confirm`` / ``delete`` for the current user. The platform brokers all
|
|
155
|
+
access; the app never holds S3 credentials.
|
|
156
|
+
"""
|
|
157
|
+
# Imported lazily so context.py stays import-light and httpx is only
|
|
158
|
+
# required when a tool actually touches files.
|
|
159
|
+
from .files import BoundFiles
|
|
160
|
+
|
|
161
|
+
return BoundFiles(self.user_id)
|
|
162
|
+
|
|
148
163
|
def log(self, level: str, message: str) -> None:
|
|
149
164
|
_log("tool", level, message)
|
|
150
165
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Per-(user, app) file storage for deployed apps.
|
|
2
|
+
|
|
3
|
+
End users can upload files THROUGH an app; each file is bound to the app
|
|
4
|
+
instance (``CLARITY_APP_ID``) and private to the uploading user. The app never
|
|
5
|
+
holds S3 credentials — it brokers every operation through the platform over
|
|
6
|
+
``${CLARITTY_PLATFORM_URL}/internal/files/*`` with the ``CLARITY_INTERNAL_SECRET``
|
|
7
|
+
shared secret (the same trust model as integration credentials,
|
|
8
|
+
:mod:`claritty_sdk.integrations.platform_creds`). The platform validates that
|
|
9
|
+
``user_id`` may access the app, then mints short-lived presigned S3 URLs.
|
|
10
|
+
|
|
11
|
+
Typical use inside a ``@tool`` handler::
|
|
12
|
+
|
|
13
|
+
from claritty_sdk import files
|
|
14
|
+
|
|
15
|
+
@tool(id="docs.save")
|
|
16
|
+
def save(input: dict, ctx: ToolCtx) -> dict:
|
|
17
|
+
info = files.upload_bytes(
|
|
18
|
+
filename=input["name"],
|
|
19
|
+
content_type=input["type"],
|
|
20
|
+
data=base64.b64decode(input["b64"]),
|
|
21
|
+
user_id=ctx.user_id,
|
|
22
|
+
)
|
|
23
|
+
return {"file_id": info["fileId"]}
|
|
24
|
+
|
|
25
|
+
Or via the bound accessor (``user_id`` taken from the context)::
|
|
26
|
+
|
|
27
|
+
info = ctx.files.upload_bytes(name, content_type, data)
|
|
28
|
+
items = ctx.files.list()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
|
|
38
|
+
# Files can be large; allow a longer ceiling than the creds fetch.
|
|
39
|
+
_TIMEOUT_S = float(os.environ.get("CLARITTY_FILES_TIMEOUT_S", "60"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FileBrokerError(RuntimeError):
|
|
43
|
+
"""A file broker call failed (misconfig, denied access, or platform error)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _config() -> tuple[str, str, Optional[str]]:
|
|
47
|
+
base = (os.environ.get("CLARITTY_PLATFORM_URL") or "").rstrip("/")
|
|
48
|
+
secret = (
|
|
49
|
+
os.environ.get("CLARITY_INTERNAL_SECRET")
|
|
50
|
+
or os.environ.get("CLARITTY_INTERNAL_SECRET")
|
|
51
|
+
or ""
|
|
52
|
+
)
|
|
53
|
+
if not base or not secret:
|
|
54
|
+
raise FileBrokerError(
|
|
55
|
+
"File broker not configured (CLARITTY_PLATFORM_URL / "
|
|
56
|
+
"CLARITY_INTERNAL_SECRET missing). In a deployed app these are "
|
|
57
|
+
"injected by the platform."
|
|
58
|
+
)
|
|
59
|
+
app_id = os.environ.get("CLARITY_APP_ID") or os.environ.get("CLARITTY_APP_ID")
|
|
60
|
+
return base, secret, app_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _post(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
64
|
+
base, secret, app_id = _config()
|
|
65
|
+
if not app_id:
|
|
66
|
+
raise FileBrokerError(
|
|
67
|
+
"CLARITY_APP_ID is not set — files are scoped per app and cannot "
|
|
68
|
+
"be brokered without it."
|
|
69
|
+
)
|
|
70
|
+
body = {"appId": app_id, **payload}
|
|
71
|
+
url = f"{base}/internal/files/{path}"
|
|
72
|
+
try:
|
|
73
|
+
resp = httpx.post(
|
|
74
|
+
url,
|
|
75
|
+
json=body,
|
|
76
|
+
headers={
|
|
77
|
+
"X-Claritty-Internal": secret,
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
},
|
|
80
|
+
timeout=_TIMEOUT_S,
|
|
81
|
+
)
|
|
82
|
+
except httpx.HTTPError as exc:
|
|
83
|
+
raise FileBrokerError(f"file broker call {path!r} failed: {exc}") from exc
|
|
84
|
+
if resp.status_code == 401:
|
|
85
|
+
raise FileBrokerError(
|
|
86
|
+
"file broker returned 401 — check CLARITY_INTERNAL_SECRET."
|
|
87
|
+
)
|
|
88
|
+
if resp.status_code == 403:
|
|
89
|
+
raise FileBrokerError("user does not have access to this app's files.")
|
|
90
|
+
if resp.status_code >= 400:
|
|
91
|
+
raise FileBrokerError(
|
|
92
|
+
f"file broker {path!r} failed: HTTP {resp.status_code} {resp.text[:200]}"
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
return resp.json() or {}
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise FileBrokerError(
|
|
98
|
+
f"file broker {path!r} returned a non-JSON response"
|
|
99
|
+
) from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_upload_url(
|
|
103
|
+
filename: str,
|
|
104
|
+
content_type: str,
|
|
105
|
+
user_id: str,
|
|
106
|
+
*,
|
|
107
|
+
size_bytes: Optional[int] = None,
|
|
108
|
+
) -> Dict[str, Any]:
|
|
109
|
+
"""Reserve a file and get a presigned PUT URL.
|
|
110
|
+
|
|
111
|
+
Returns ``{"fileId", "uploadUrl", "contentType"}``. PUT the bytes to
|
|
112
|
+
``uploadUrl`` with ``Content-Type: contentType``, then call :func:`confirm`.
|
|
113
|
+
Prefer :func:`upload_bytes` which does all three steps.
|
|
114
|
+
"""
|
|
115
|
+
payload: Dict[str, Any] = {
|
|
116
|
+
"userId": user_id,
|
|
117
|
+
"filename": filename,
|
|
118
|
+
"contentType": content_type,
|
|
119
|
+
}
|
|
120
|
+
if size_bytes is not None:
|
|
121
|
+
payload["sizeBytes"] = int(size_bytes)
|
|
122
|
+
return _post("upload-url", payload)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def confirm(file_id: str, user_id: str) -> Dict[str, Any]:
|
|
126
|
+
"""Mark an uploaded object READY (records its real size). Returns the file."""
|
|
127
|
+
return _post("confirm", {"userId": user_id, "fileId": file_id})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def list_files(user_id: str) -> List[Dict[str, Any]]:
|
|
131
|
+
"""List the user's READY files for this app (newest first)."""
|
|
132
|
+
return _post("list", {"userId": user_id}).get("files", [])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def download_url(file_id: str, user_id: str) -> Dict[str, Any]:
|
|
136
|
+
"""Get a short-lived presigned GET URL: ``{"url", "filename", "contentType"}``."""
|
|
137
|
+
return _post("download-url", {"userId": user_id, "fileId": file_id})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def delete(file_id: str, user_id: str) -> Dict[str, Any]:
|
|
141
|
+
"""Delete one of the user's own files (object + metadata)."""
|
|
142
|
+
return _post("delete", {"userId": user_id, "fileId": file_id})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def upload_bytes(
|
|
146
|
+
filename: str,
|
|
147
|
+
content_type: str,
|
|
148
|
+
data: bytes,
|
|
149
|
+
user_id: str,
|
|
150
|
+
) -> Dict[str, Any]:
|
|
151
|
+
"""One-shot server-side upload: reserve → PUT to S3 → confirm.
|
|
152
|
+
|
|
153
|
+
Returns the confirmed file (``{"fileId", "filename", "sizeBytes", ...}``).
|
|
154
|
+
Note Lambda's ~6MB sync payload cap — for large browser uploads, hand the
|
|
155
|
+
presigned ``uploadUrl`` from :func:`create_upload_url` to the client and PUT
|
|
156
|
+
directly from the browser instead of streaming bytes through the app.
|
|
157
|
+
"""
|
|
158
|
+
reserved = create_upload_url(
|
|
159
|
+
filename, content_type, user_id, size_bytes=len(data)
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
put = httpx.put(
|
|
163
|
+
reserved["uploadUrl"],
|
|
164
|
+
content=data,
|
|
165
|
+
headers={"Content-Type": reserved.get("contentType", content_type)},
|
|
166
|
+
timeout=_TIMEOUT_S,
|
|
167
|
+
)
|
|
168
|
+
except httpx.HTTPError as exc:
|
|
169
|
+
raise FileBrokerError(f"upload PUT failed: {exc}") from exc
|
|
170
|
+
if put.status_code >= 400:
|
|
171
|
+
raise FileBrokerError(
|
|
172
|
+
f"upload PUT failed: HTTP {put.status_code} {put.text[:200]}"
|
|
173
|
+
)
|
|
174
|
+
return confirm(reserved["fileId"], user_id)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class BoundFiles:
|
|
178
|
+
""":mod:`claritty_sdk.files` with ``user_id`` pre-bound (see ``ToolCtx.files``)."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, user_id: Optional[str]) -> None:
|
|
181
|
+
self._user_id = user_id
|
|
182
|
+
|
|
183
|
+
def _uid(self) -> str:
|
|
184
|
+
if not self._user_id:
|
|
185
|
+
raise FileBrokerError(
|
|
186
|
+
"no user_id on this context — files are per-user and cannot be "
|
|
187
|
+
"accessed without an authenticated user."
|
|
188
|
+
)
|
|
189
|
+
return self._user_id
|
|
190
|
+
|
|
191
|
+
def create_upload_url(
|
|
192
|
+
self,
|
|
193
|
+
filename: str,
|
|
194
|
+
content_type: str,
|
|
195
|
+
*,
|
|
196
|
+
size_bytes: Optional[int] = None,
|
|
197
|
+
) -> Dict[str, Any]:
|
|
198
|
+
return create_upload_url(
|
|
199
|
+
filename, content_type, self._uid(), size_bytes=size_bytes
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def upload_bytes(
|
|
203
|
+
self, filename: str, content_type: str, data: bytes
|
|
204
|
+
) -> Dict[str, Any]:
|
|
205
|
+
return upload_bytes(filename, content_type, data, self._uid())
|
|
206
|
+
|
|
207
|
+
def confirm(self, file_id: str) -> Dict[str, Any]:
|
|
208
|
+
return confirm(file_id, self._uid())
|
|
209
|
+
|
|
210
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
211
|
+
return list_files(self._uid())
|
|
212
|
+
|
|
213
|
+
def download_url(self, file_id: str) -> Dict[str, Any]:
|
|
214
|
+
return download_url(file_id, self._uid())
|
|
215
|
+
|
|
216
|
+
def delete(self, file_id: str) -> Dict[str, Any]:
|
|
217
|
+
return delete(file_id, self._uid())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
__all__ = [
|
|
221
|
+
"FileBrokerError",
|
|
222
|
+
"create_upload_url",
|
|
223
|
+
"confirm",
|
|
224
|
+
"list_files",
|
|
225
|
+
"download_url",
|
|
226
|
+
"delete",
|
|
227
|
+
"upload_bytes",
|
|
228
|
+
"BoundFiles",
|
|
229
|
+
]
|
|
@@ -91,8 +91,8 @@ def _wire_native_integration(
|
|
|
91
91
|
"""Register a PLATFORM-DELEGATING handler for each tool a native integration
|
|
92
92
|
advertises. The real provider REST + the user's credentials live on the
|
|
93
93
|
platform (the seed is a public repo), so the app never executes the call
|
|
94
|
-
itself — it forwards to /internal/integrations/<id>/<tool>/execute via
|
|
95
|
-
SDK PlatformClient. Skips any tool already registered (a vendored handler,
|
|
94
|
+
itself — it forwards to /internal/integrations/tools/<id>/<tool>/execute via
|
|
95
|
+
the SDK PlatformClient. Skips any tool already registered (a vendored handler,
|
|
96
96
|
if present, wins)."""
|
|
97
97
|
for t in manifest.get("providedTools") or []:
|
|
98
98
|
tool_id = (t or {}).get("id")
|
|
@@ -60,10 +60,18 @@ def execute_tool(
|
|
|
60
60
|
app_id = os.environ.get("CLARITY_APP_ID")
|
|
61
61
|
if app_id:
|
|
62
62
|
payload["appId"] = app_id
|
|
63
|
+
headers = {"X-Claritty-Internal": secret}
|
|
64
|
+
# Per-app identity: PROVE our appId to the broker with the per-app secret
|
|
65
|
+
# (HMAC(master, appId), injected as CLARITY_APP_INTEGRATION_SECRET) so a
|
|
66
|
+
# different app can't claim ours. Omitted when absent (legacy) → broker falls
|
|
67
|
+
# back to the shared-secret path until strict mode.
|
|
68
|
+
app_secret = os.environ.get("CLARITY_APP_INTEGRATION_SECRET")
|
|
69
|
+
if app_secret:
|
|
70
|
+
headers["X-Claritty-App-Secret"] = app_secret
|
|
63
71
|
try:
|
|
64
72
|
resp = httpx.post(
|
|
65
73
|
url,
|
|
66
|
-
headers=
|
|
74
|
+
headers=headers,
|
|
67
75
|
json=payload,
|
|
68
76
|
timeout=timeout,
|
|
69
77
|
)
|
|
@@ -28,6 +28,26 @@ logger = logging.getLogger("claritty_sdk.integrations.platform_creds")
|
|
|
28
28
|
_FETCH_TIMEOUT_S = float(os.environ.get("CLARITTY_CREDS_TIMEOUT_S", "10"))
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _broker_headers(secret: str) -> Dict[str, str]:
|
|
32
|
+
"""Headers for a platform integration-broker call.
|
|
33
|
+
|
|
34
|
+
``X-Claritty-Internal`` is the shared transport secret. ``X-Claritty-App-Secret``
|
|
35
|
+
is THIS app's PER-APP secret — ``HMAC(master, appId)``, injected by the
|
|
36
|
+
platform as ``CLARITY_APP_INTEGRATION_SECRET`` — which the broker verifies to
|
|
37
|
+
PROVE our ``appId`` so a different app can't claim ours and pull our users'
|
|
38
|
+
connections. Omitted when absent (legacy/local), so the broker falls back to
|
|
39
|
+
the shared-secret path until it's switched to strict mode.
|
|
40
|
+
"""
|
|
41
|
+
headers = {
|
|
42
|
+
"X-Claritty-Internal": secret,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
}
|
|
45
|
+
app_secret = os.environ.get("CLARITY_APP_INTEGRATION_SECRET")
|
|
46
|
+
if app_secret:
|
|
47
|
+
headers["X-Claritty-App-Secret"] = app_secret
|
|
48
|
+
return headers
|
|
49
|
+
|
|
50
|
+
|
|
31
51
|
class CredentialsNotAvailable(RuntimeError):
|
|
32
52
|
"""Raised when an integration's credentials cannot be fetched for
|
|
33
53
|
the current user (not connected, expired, or — during Phase 2 —
|
|
@@ -122,10 +142,7 @@ def fetch_for_user(integration_id: str, user_id: str) -> Credentials:
|
|
|
122
142
|
res = client.post(
|
|
123
143
|
url,
|
|
124
144
|
json=payload,
|
|
125
|
-
headers=
|
|
126
|
-
"X-Claritty-Internal": secret,
|
|
127
|
-
"Content-Type": "application/json",
|
|
128
|
-
},
|
|
145
|
+
headers=_broker_headers(secret),
|
|
129
146
|
)
|
|
130
147
|
except httpx.HTTPError as exc:
|
|
131
148
|
# Network/timeout — surface as not-available so tools degrade, but log.
|
|
@@ -154,4 +171,94 @@ def fetch_for_user(integration_id: str, user_id: str) -> Credentials:
|
|
|
154
171
|
)
|
|
155
172
|
|
|
156
173
|
|
|
157
|
-
|
|
174
|
+
def is_connected(integration_id: str, user_id: str) -> bool:
|
|
175
|
+
"""Whether ``integration_id`` is connected for ``user_id`` in THIS app.
|
|
176
|
+
|
|
177
|
+
A CREDENTIAL-FREE probe: it asks the platform's state endpoint whether a
|
|
178
|
+
connection exists WITHOUT ever fetching the raw token — so it works even for
|
|
179
|
+
brokered / gate-only integrations (where ``fetch_for_user`` is refused). Use
|
|
180
|
+
this for connect prompts / setup checklists; act on an integration only via
|
|
181
|
+
its registered tool handlers (which reach the platform broker).
|
|
182
|
+
|
|
183
|
+
Fail-closed: returns ``False`` on not-connected / misconfig / any transient
|
|
184
|
+
error, so a checklist degrades to "connect" rather than crashing. Scoped to
|
|
185
|
+
THIS app via ``CLARITY_APP_ID`` (same as ``fetch_for_user``).
|
|
186
|
+
"""
|
|
187
|
+
# Local dev override mirrors fetch_for_user: a fake cred means "connected".
|
|
188
|
+
if os.environ.get(f"CLARITTY_FAKE_CREDS_{integration_id.upper()}"):
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
base = (os.environ.get("CLARITTY_PLATFORM_URL") or "").rstrip("/")
|
|
192
|
+
secret = os.environ.get("CLARITY_INTERNAL_SECRET") or ""
|
|
193
|
+
if not base or not secret:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
import httpx
|
|
197
|
+
|
|
198
|
+
payload = {"userId": user_id, "integrationCatalogId": integration_id}
|
|
199
|
+
app_id = os.environ.get("CLARITY_APP_ID")
|
|
200
|
+
if app_id:
|
|
201
|
+
payload["appId"] = app_id
|
|
202
|
+
try:
|
|
203
|
+
with httpx.Client(timeout=_FETCH_TIMEOUT_S) as client:
|
|
204
|
+
res = client.post(
|
|
205
|
+
f"{base}/internal/integrations/state",
|
|
206
|
+
json=payload,
|
|
207
|
+
headers=_broker_headers(secret),
|
|
208
|
+
)
|
|
209
|
+
if res.status_code >= 400:
|
|
210
|
+
return False
|
|
211
|
+
return bool((res.json() or {}).get("connected"))
|
|
212
|
+
except Exception as exc: # noqa: BLE001 — a probe must never crash a checklist
|
|
213
|
+
logger.warning(
|
|
214
|
+
"connection state for integration=%s failed: %s", integration_id, exc
|
|
215
|
+
)
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _platform_app_url() -> str:
|
|
220
|
+
"""Public URL of the Claritty platform app (where the connect popup runs), as
|
|
221
|
+
distinct from CLARITTY_PLATFORM_URL (the internal API base the broker calls).
|
|
222
|
+
Read at call time so a late-set env var is honored."""
|
|
223
|
+
return (
|
|
224
|
+
os.environ.get("CLARITTY_PLATFORM_APP_URL") or "https://app.claritty.ai"
|
|
225
|
+
).rstrip("/")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def connect_url(integration_id: str) -> Optional[str]:
|
|
229
|
+
"""Deep link to the platform-hosted, per-(user,app) connect surface for
|
|
230
|
+
``integration_id`` — the URL a deployed app opens (popup) so the user can
|
|
231
|
+
connect WITHOUT the app ever handling a credential. The popup runs at
|
|
232
|
+
``app.claritty.ai`` and identifies the user by their platform session.
|
|
233
|
+
|
|
234
|
+
Returns ``None`` when ``CLARITY_APP_ID`` isn't set (can't scope the connect
|
|
235
|
+
to an app — e.g. local runs without the platform), so callers can omit the
|
|
236
|
+
CTA gracefully.
|
|
237
|
+
"""
|
|
238
|
+
app_id = os.environ.get("CLARITY_APP_ID")
|
|
239
|
+
if not app_id:
|
|
240
|
+
return None
|
|
241
|
+
return f"{_platform_app_url()}/connect/{app_id}/{integration_id}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def not_connected_error(integration_id: str) -> Dict[str, Any]:
|
|
245
|
+
"""The STANDARD not-connected result every tool/adapter should return when an
|
|
246
|
+
integration isn't connected, so the app UI can render a "Connect X" CTA with
|
|
247
|
+
zero extra round-trips. Shape:
|
|
248
|
+
``{"error": "<id>_not_connected", "service": <id>, "connect_url": <url|None>}``.
|
|
249
|
+
"""
|
|
250
|
+
return {
|
|
251
|
+
"error": f"{integration_id}_not_connected",
|
|
252
|
+
"service": integration_id,
|
|
253
|
+
"connect_url": connect_url(integration_id),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
__all__ = [
|
|
258
|
+
"Credentials",
|
|
259
|
+
"CredentialsNotAvailable",
|
|
260
|
+
"fetch_for_user",
|
|
261
|
+
"is_connected",
|
|
262
|
+
"connect_url",
|
|
263
|
+
"not_connected_error",
|
|
264
|
+
]
|
|
@@ -134,8 +134,11 @@ class PlatformClient:
|
|
|
134
134
|
# tool_id may arrive dotted (linkedin.create_post); the endpoint wants
|
|
135
135
|
# the bare tool segment.
|
|
136
136
|
bare = tool_id.split(".", 1)[1] if "." in tool_id else tool_id
|
|
137
|
+
# Canonical broker route is `/internal/integrations/tools/{id}/{tool}/execute`
|
|
138
|
+
# (see clarity-api InternalIntegrationsController). Earlier builds omitted
|
|
139
|
+
# the `tools/` segment and every brokered call 404'd — keep the segment.
|
|
137
140
|
url = (
|
|
138
|
-
f"{self.base_url}/internal/integrations/"
|
|
141
|
+
f"{self.base_url}/internal/integrations/tools/"
|
|
139
142
|
f"{integration_id}/{bare}/execute"
|
|
140
143
|
)
|
|
141
144
|
# Per-app scoping: platform serves the connection bound to THIS app.
|
|
@@ -143,22 +146,33 @@ class PlatformClient:
|
|
|
143
146
|
app_id = os.environ.get("CLARITY_APP_ID")
|
|
144
147
|
if app_id:
|
|
145
148
|
payload["appId"] = app_id
|
|
149
|
+
headers = {
|
|
150
|
+
"X-Claritty-Internal": self._secret,
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
}
|
|
153
|
+
# Per-app identity: PROVE our appId to the broker with the per-app secret
|
|
154
|
+
# (HMAC(master, appId), injected as CLARITY_APP_INTEGRATION_SECRET) so a
|
|
155
|
+
# different app can't claim ours. Omitted when absent (legacy) → broker
|
|
156
|
+
# falls back to the shared-secret path until strict mode.
|
|
157
|
+
app_secret = os.environ.get("CLARITY_APP_INTEGRATION_SECRET")
|
|
158
|
+
if app_secret:
|
|
159
|
+
headers["X-Claritty-App-Secret"] = app_secret
|
|
146
160
|
try:
|
|
147
161
|
with httpx.Client(timeout=self._timeout) as http:
|
|
148
|
-
res = http.post(
|
|
149
|
-
url,
|
|
150
|
-
json=payload,
|
|
151
|
-
headers={
|
|
152
|
-
"X-Claritty-Internal": self._secret,
|
|
153
|
-
"Content-Type": "application/json",
|
|
154
|
-
},
|
|
155
|
-
)
|
|
162
|
+
res = http.post(url, json=payload, headers=headers)
|
|
156
163
|
except httpx.HTTPError as exc:
|
|
157
164
|
raise RuntimeError(
|
|
158
165
|
f"integration tool {integration_id}.{bare} call failed: {exc}"
|
|
159
166
|
) from exc
|
|
160
167
|
if res.status_code == 409:
|
|
161
168
|
return {"error": f"{integration_id}_not_connected"}
|
|
169
|
+
if res.status_code == 404:
|
|
170
|
+
# The tool/route/executor isn't brokered for this app (unbrokered
|
|
171
|
+
# catalog integration, or a stale route). DEGRADE rather than raise —
|
|
172
|
+
# a brokered call is optional from the app's view, so a hard crash here
|
|
173
|
+
# would break an otherwise-working flow (this 500'd onboarding when
|
|
174
|
+
# brave-search had no executor). Callers see the not-connected signal.
|
|
175
|
+
return {"error": f"{integration_id}_not_connected"}
|
|
162
176
|
if res.status_code == 401:
|
|
163
177
|
raise RuntimeError(
|
|
164
178
|
f"integration execute 401 from {url} — check CLARITY_INTERNAL_SECRET."
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Phase 3: the agent tool-loop must BROKER integration-provided tools through
|
|
2
|
+
the platform executor — the credential never reaches the app. A catalog-source
|
|
3
|
+
tool with no locally-registered handler is dispatched via the platform delegate
|
|
4
|
+
(PlatformClient.execute_integration_tool), and only its result is returned.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from claritty_sdk.runtime import tool_loop
|
|
9
|
+
from claritty_sdk.runtime import platform_client
|
|
10
|
+
from claritty_sdk.manifest import ToolDecl
|
|
11
|
+
from claritty_sdk.context import AgentContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ctx():
|
|
15
|
+
return AgentContext(user_id="u1")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_integration_tool_is_brokered(monkeypatch):
|
|
19
|
+
seen = {}
|
|
20
|
+
|
|
21
|
+
def fake_exec(self, integration_id, tool_id, user_id, arguments):
|
|
22
|
+
seen.update(
|
|
23
|
+
integration_id=integration_id,
|
|
24
|
+
tool_id=tool_id,
|
|
25
|
+
user_id=user_id,
|
|
26
|
+
arguments=arguments,
|
|
27
|
+
)
|
|
28
|
+
return {"message_id": "m1"}
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(
|
|
31
|
+
platform_client.PlatformClient, "execute_integration_tool", fake_exec
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
toolset = {
|
|
35
|
+
"gmail.send": ToolDecl(
|
|
36
|
+
id="gmail.send", source="catalog", handler="", input={}, output={}
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
out = asyncio.run(
|
|
40
|
+
tool_loop._dispatch_tool(
|
|
41
|
+
"gmail.send", {"to": "x@y.com"}, toolset, _ctx(), None
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
# Only the result comes back — no credential.
|
|
45
|
+
assert out == {"message_id": "m1"}
|
|
46
|
+
assert seen["integration_id"] == "gmail"
|
|
47
|
+
assert seen["user_id"] == "u1"
|
|
48
|
+
assert seen["arguments"] == {"to": "x@y.com"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_execute_posts_to_canonical_tools_route(monkeypatch):
|
|
52
|
+
"""The broker POST must target the canonical
|
|
53
|
+
`/internal/integrations/tools/{id}/{tool}/execute` route the platform
|
|
54
|
+
serves. Omitting the `tools/` segment 404s every brokered call — this
|
|
55
|
+
guards that regression."""
|
|
56
|
+
captured = {}
|
|
57
|
+
|
|
58
|
+
class _Resp:
|
|
59
|
+
status_code = 200
|
|
60
|
+
content = b"{}"
|
|
61
|
+
|
|
62
|
+
def json(self):
|
|
63
|
+
return {"result": {"ok": True}}
|
|
64
|
+
|
|
65
|
+
class _FakeClient:
|
|
66
|
+
def __init__(self, *a, **k):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *a):
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def post(self, url, **kwargs):
|
|
76
|
+
captured["url"] = url
|
|
77
|
+
captured["json"] = kwargs.get("json")
|
|
78
|
+
return _Resp()
|
|
79
|
+
|
|
80
|
+
monkeypatch.setenv("CLARITTY_PLATFORM_URL", "https://api.claritty.ai")
|
|
81
|
+
monkeypatch.setenv("CLARITY_INTERNAL_SECRET", "sec")
|
|
82
|
+
monkeypatch.setattr(platform_client.httpx, "Client", _FakeClient)
|
|
83
|
+
|
|
84
|
+
out = platform_client.PlatformClient().execute_integration_tool(
|
|
85
|
+
"hubspot", "hubspot.search_contacts", "u1", {"query": "acme"}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
assert out == {"ok": True}
|
|
89
|
+
assert (
|
|
90
|
+
captured["url"]
|
|
91
|
+
== "https://api.claritty.ai/internal/integrations/tools/hubspot/search_contacts/execute"
|
|
92
|
+
)
|
|
93
|
+
assert captured["json"]["userId"] == "u1"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_404_degrades_to_not_connected(monkeypatch):
|
|
97
|
+
"""An unbrokered integration / stale route (404) must DEGRADE to a
|
|
98
|
+
not-connected signal, not raise — a raise here 500'd onboarding when
|
|
99
|
+
`brave-search.search` had no executor."""
|
|
100
|
+
class _Resp:
|
|
101
|
+
status_code = 404
|
|
102
|
+
content = b'{"message":"Cannot POST ..."}'
|
|
103
|
+
text = '{"message":"Cannot POST ..."}'
|
|
104
|
+
|
|
105
|
+
def json(self):
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
class _FakeClient:
|
|
109
|
+
def __init__(self, *a, **k):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def __enter__(self):
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, *a):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def post(self, url, **kwargs):
|
|
119
|
+
return _Resp()
|
|
120
|
+
|
|
121
|
+
monkeypatch.setenv("CLARITTY_PLATFORM_URL", "https://api.claritty.ai")
|
|
122
|
+
monkeypatch.setenv("CLARITY_INTERNAL_SECRET", "sec")
|
|
123
|
+
monkeypatch.setattr(platform_client.httpx, "Client", _FakeClient)
|
|
124
|
+
|
|
125
|
+
out = platform_client.PlatformClient().execute_integration_tool(
|
|
126
|
+
"brave-search", "brave-search.search", "u1", {"query": "x"}
|
|
127
|
+
)
|
|
128
|
+
assert out == {"error": "brave-search_not_connected"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_unknown_custom_tool_still_errors():
|
|
132
|
+
toolset = {
|
|
133
|
+
"foo.bar": ToolDecl(
|
|
134
|
+
id="foo.bar", source="custom", handler="", input={}, output={}
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
out = asyncio.run(
|
|
138
|
+
tool_loop._dispatch_tool("foo.bar", {}, toolset, _ctx(), None)
|
|
139
|
+
)
|
|
140
|
+
assert "no registered handler" in out.get("error", "")
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
"""Phase 3: the agent tool-loop must BROKER integration-provided tools through
|
|
2
|
-
the platform executor — the credential never reaches the app. A catalog-source
|
|
3
|
-
tool with no locally-registered handler is dispatched via the platform delegate
|
|
4
|
-
(PlatformClient.execute_integration_tool), and only its result is returned.
|
|
5
|
-
"""
|
|
6
|
-
import asyncio
|
|
7
|
-
|
|
8
|
-
from claritty_sdk.runtime import tool_loop
|
|
9
|
-
from claritty_sdk.runtime import platform_client
|
|
10
|
-
from claritty_sdk.manifest import ToolDecl
|
|
11
|
-
from claritty_sdk.context import AgentContext
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _ctx():
|
|
15
|
-
return AgentContext(user_id="u1")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_integration_tool_is_brokered(monkeypatch):
|
|
19
|
-
seen = {}
|
|
20
|
-
|
|
21
|
-
def fake_exec(self, integration_id, tool_id, user_id, arguments):
|
|
22
|
-
seen.update(
|
|
23
|
-
integration_id=integration_id,
|
|
24
|
-
tool_id=tool_id,
|
|
25
|
-
user_id=user_id,
|
|
26
|
-
arguments=arguments,
|
|
27
|
-
)
|
|
28
|
-
return {"message_id": "m1"}
|
|
29
|
-
|
|
30
|
-
monkeypatch.setattr(
|
|
31
|
-
platform_client.PlatformClient, "execute_integration_tool", fake_exec
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
toolset = {
|
|
35
|
-
"gmail.send": ToolDecl(
|
|
36
|
-
id="gmail.send", source="catalog", handler="", input={}, output={}
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
out = asyncio.run(
|
|
40
|
-
tool_loop._dispatch_tool(
|
|
41
|
-
"gmail.send", {"to": "x@y.com"}, toolset, _ctx(), None
|
|
42
|
-
)
|
|
43
|
-
)
|
|
44
|
-
# Only the result comes back — no credential.
|
|
45
|
-
assert out == {"message_id": "m1"}
|
|
46
|
-
assert seen["integration_id"] == "gmail"
|
|
47
|
-
assert seen["user_id"] == "u1"
|
|
48
|
-
assert seen["arguments"] == {"to": "x@y.com"}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_unknown_custom_tool_still_errors():
|
|
52
|
-
toolset = {
|
|
53
|
-
"foo.bar": ToolDecl(
|
|
54
|
-
id="foo.bar", source="custom", handler="", input={}, output={}
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
out = asyncio.run(
|
|
58
|
-
tool_loop._dispatch_tool("foo.bar", {}, toolset, _ctx(), None)
|
|
59
|
-
)
|
|
60
|
-
assert "no registered handler" in out.get("error", "")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|