claritty-sdk 2.2.0__tar.gz → 2.4.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.
Files changed (80) hide show
  1. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/PKG-INFO +1 -1
  2. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/__init__.py +4 -2
  3. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/context.py +15 -0
  4. claritty_sdk-2.4.0/claritty_sdk/files.py +229 -0
  5. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/catalog.py +2 -2
  6. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/platform_creds.py +94 -1
  7. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/platform_client.py +11 -1
  8. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk.egg-info/PKG-INFO +1 -1
  9. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk.egg-info/SOURCES.txt +1 -0
  10. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/pyproject.toml +1 -1
  11. claritty_sdk-2.4.0/tests/test_integration_broker_dispatch.py +140 -0
  12. claritty_sdk-2.2.0/tests/test_integration_broker_dispatch.py +0 -60
  13. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/README.md +0 -0
  14. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/cli_eval.py +0 -0
  15. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/cli_verify.py +0 -0
  16. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/decorators.py +0 -0
  17. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/eval.py +0 -0
  18. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/graph.py +0 -0
  19. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/__init__.py +0 -0
  20. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/__init__.pyi +0 -0
  21. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/airtable.pyi +0 -0
  22. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/anthropic.pyi +0 -0
  23. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/asana.pyi +0 -0
  24. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/calendly.pyi +0 -0
  25. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/clickup.pyi +0 -0
  26. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/client.py +0 -0
  27. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/discord.pyi +0 -0
  28. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/dropbox.pyi +0 -0
  29. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/executor_client.py +0 -0
  30. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/github.pyi +0 -0
  31. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/gitlab.pyi +0 -0
  32. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/gmail.pyi +0 -0
  33. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/google_calendar.pyi +0 -0
  34. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/google_drive.pyi +0 -0
  35. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/hubspot.pyi +0 -0
  36. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/jira.pyi +0 -0
  37. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/linear.pyi +0 -0
  38. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/linkedin.pyi +0 -0
  39. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/mailchimp.pyi +0 -0
  40. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/mcp_client.py +0 -0
  41. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/microsoft_teams.pyi +0 -0
  42. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/notion.pyi +0 -0
  43. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/notion_mcp.pyi +0 -0
  44. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/openai.pyi +0 -0
  45. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/outlook.pyi +0 -0
  46. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/outlook_calendar.pyi +0 -0
  47. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/pipedrive.pyi +0 -0
  48. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/postgres.pyi +0 -0
  49. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/posthog.pyi +0 -0
  50. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/s3.pyi +0 -0
  51. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/salesforce.pyi +0 -0
  52. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/slack.pyi +0 -0
  53. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/stripe.pyi +0 -0
  54. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/trello.pyi +0 -0
  55. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/twilio.pyi +0 -0
  56. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/integrations/x_twitter.pyi +0 -0
  57. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/llm.py +0 -0
  58. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/manifest.py +0 -0
  59. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/__init__.py +0 -0
  60. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/bootstrap.py +0 -0
  61. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/expressions.py +0 -0
  62. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/schema_validate.py +0 -0
  63. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/team_runner.py +0 -0
  64. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/tool_loop.py +0 -0
  65. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/tracing.py +0 -0
  66. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/runtime/workflow_engine.py +0 -0
  67. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk/validators.py +0 -0
  68. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk.egg-info/dependency_links.txt +0 -0
  69. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk.egg-info/entry_points.txt +0 -0
  70. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk.egg-info/requires.txt +0 -0
  71. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/claritty_sdk.egg-info/top_level.txt +0 -0
  72. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/setup.cfg +0 -0
  73. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_agent_reasoning.py +0 -0
  74. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_execution_end_to_end.py +0 -0
  75. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_graph_emitter.py +0 -0
  76. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_manifest_doc_parity.py +0 -0
  77. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_manifest_triggers.py +0 -0
  78. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_parallel_fanin.py +0 -0
  79. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_team_mode.py +0 -0
  80. {claritty_sdk-2.2.0 → claritty_sdk-2.4.0}/tests/test_workflow_engine_failfast.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claritty-sdk
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Official SDK for building apps on the Claritty Platform
5
5
  Author: Claritty Platform
6
6
  License: MIT
@@ -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.2.0"
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 the
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")
@@ -154,4 +154,97 @@ def fetch_for_user(integration_id: str, user_id: str) -> Credentials:
154
154
  )
155
155
 
156
156
 
157
- __all__ = ["Credentials", "CredentialsNotAvailable", "fetch_for_user"]
157
+ def is_connected(integration_id: str, user_id: str) -> bool:
158
+ """Whether ``integration_id`` is connected for ``user_id`` in THIS app.
159
+
160
+ A CREDENTIAL-FREE probe: it asks the platform's state endpoint whether a
161
+ connection exists WITHOUT ever fetching the raw token — so it works even for
162
+ brokered / gate-only integrations (where ``fetch_for_user`` is refused). Use
163
+ this for connect prompts / setup checklists; act on an integration only via
164
+ its registered tool handlers (which reach the platform broker).
165
+
166
+ Fail-closed: returns ``False`` on not-connected / misconfig / any transient
167
+ error, so a checklist degrades to "connect" rather than crashing. Scoped to
168
+ THIS app via ``CLARITY_APP_ID`` (same as ``fetch_for_user``).
169
+ """
170
+ # Local dev override mirrors fetch_for_user: a fake cred means "connected".
171
+ if os.environ.get(f"CLARITTY_FAKE_CREDS_{integration_id.upper()}"):
172
+ return True
173
+
174
+ base = (os.environ.get("CLARITTY_PLATFORM_URL") or "").rstrip("/")
175
+ secret = os.environ.get("CLARITY_INTERNAL_SECRET") or ""
176
+ if not base or not secret:
177
+ return False
178
+
179
+ import httpx
180
+
181
+ payload = {"userId": user_id, "integrationCatalogId": integration_id}
182
+ app_id = os.environ.get("CLARITY_APP_ID")
183
+ if app_id:
184
+ payload["appId"] = app_id
185
+ try:
186
+ with httpx.Client(timeout=_FETCH_TIMEOUT_S) as client:
187
+ res = client.post(
188
+ f"{base}/internal/integrations/state",
189
+ json=payload,
190
+ headers={
191
+ "X-Claritty-Internal": secret,
192
+ "Content-Type": "application/json",
193
+ },
194
+ )
195
+ if res.status_code >= 400:
196
+ return False
197
+ return bool((res.json() or {}).get("connected"))
198
+ except Exception as exc: # noqa: BLE001 — a probe must never crash a checklist
199
+ logger.warning(
200
+ "connection state for integration=%s failed: %s", integration_id, exc
201
+ )
202
+ return False
203
+
204
+
205
+ def _platform_app_url() -> str:
206
+ """Public URL of the Claritty platform app (where the connect popup runs), as
207
+ distinct from CLARITTY_PLATFORM_URL (the internal API base the broker calls).
208
+ Read at call time so a late-set env var is honored."""
209
+ return (
210
+ os.environ.get("CLARITTY_PLATFORM_APP_URL") or "https://app.claritty.ai"
211
+ ).rstrip("/")
212
+
213
+
214
+ def connect_url(integration_id: str) -> Optional[str]:
215
+ """Deep link to the platform-hosted, per-(user,app) connect surface for
216
+ ``integration_id`` — the URL a deployed app opens (popup) so the user can
217
+ connect WITHOUT the app ever handling a credential. The popup runs at
218
+ ``app.claritty.ai`` and identifies the user by their platform session.
219
+
220
+ Returns ``None`` when ``CLARITY_APP_ID`` isn't set (can't scope the connect
221
+ to an app — e.g. local runs without the platform), so callers can omit the
222
+ CTA gracefully.
223
+ """
224
+ app_id = os.environ.get("CLARITY_APP_ID")
225
+ if not app_id:
226
+ return None
227
+ return f"{_platform_app_url()}/connect/{app_id}/{integration_id}"
228
+
229
+
230
+ def not_connected_error(integration_id: str) -> Dict[str, Any]:
231
+ """The STANDARD not-connected result every tool/adapter should return when an
232
+ integration isn't connected, so the app UI can render a "Connect X" CTA with
233
+ zero extra round-trips. Shape:
234
+ ``{"error": "<id>_not_connected", "service": <id>, "connect_url": <url|None>}``.
235
+ """
236
+ return {
237
+ "error": f"{integration_id}_not_connected",
238
+ "service": integration_id,
239
+ "connect_url": connect_url(integration_id),
240
+ }
241
+
242
+
243
+ __all__ = [
244
+ "Credentials",
245
+ "CredentialsNotAvailable",
246
+ "fetch_for_user",
247
+ "is_connected",
248
+ "connect_url",
249
+ "not_connected_error",
250
+ ]
@@ -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.
@@ -159,6 +162,13 @@ class PlatformClient:
159
162
  ) from exc
160
163
  if res.status_code == 409:
161
164
  return {"error": f"{integration_id}_not_connected"}
165
+ if res.status_code == 404:
166
+ # The tool/route/executor isn't brokered for this app (unbrokered
167
+ # catalog integration, or a stale route). DEGRADE rather than raise —
168
+ # a brokered call is optional from the app's view, so a hard crash here
169
+ # would break an otherwise-working flow (this 500'd onboarding when
170
+ # brave-search had no executor). Callers see the not-connected signal.
171
+ return {"error": f"{integration_id}_not_connected"}
162
172
  if res.status_code == 401:
163
173
  raise RuntimeError(
164
174
  f"integration execute 401 from {url} — check CLARITY_INTERNAL_SECRET."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claritty-sdk
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Official SDK for building apps on the Claritty Platform
5
5
  Author: Claritty Platform
6
6
  License: MIT
@@ -6,6 +6,7 @@ claritty_sdk/cli_verify.py
6
6
  claritty_sdk/context.py
7
7
  claritty_sdk/decorators.py
8
8
  claritty_sdk/eval.py
9
+ claritty_sdk/files.py
9
10
  claritty_sdk/graph.py
10
11
  claritty_sdk/llm.py
11
12
  claritty_sdk/manifest.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claritty-sdk"
7
- version = "2.2.0"
7
+ version = "2.4.0"
8
8
  description = "Official SDK for building apps on the Claritty Platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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