mobileops-managed-agent 0.3.0__tar.gz → 0.3.2__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 (26) hide show
  1. {mobileops_managed_agent-0.3.0/src/mobileops_managed_agent.egg-info → mobileops_managed_agent-0.3.2}/PKG-INFO +1 -1
  2. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/pyproject.toml +1 -1
  3. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/__init__.py +21 -0
  4. mobileops_managed_agent-0.3.2/src/mobileops_managed_agent/artifacts.py +134 -0
  5. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/assistant_model.py +13 -1
  6. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/assistants.py +11 -1
  7. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/capabilities.py +2 -2
  8. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/client.py +11 -1
  9. mobileops_managed_agent-0.3.2/src/mobileops_managed_agent/model_retry.py +64 -0
  10. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/resources.py +17 -3
  11. mobileops_managed_agent-0.3.2/src/mobileops_managed_agent/tool_permissions.py +97 -0
  12. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2/src/mobileops_managed_agent.egg-info}/PKG-INFO +1 -1
  13. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent.egg-info/SOURCES.txt +3 -0
  14. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/MANIFEST.in +0 -0
  15. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/README.md +0 -0
  16. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/setup.cfg +0 -0
  17. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/auth.py +0 -0
  18. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/credential_vault.py +0 -0
  19. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/files.py +0 -0
  20. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/response_schema.py +0 -0
  21. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/sandbox_profile.py +0 -0
  22. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/sandbox_profiles.py +0 -0
  23. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent/vaults.py +0 -0
  24. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent.egg-info/dependency_links.txt +0 -0
  25. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent.egg-info/requires.txt +0 -0
  26. {mobileops_managed_agent-0.3.0 → mobileops_managed_agent-0.3.2}/src/mobileops_managed_agent.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mobileops-managed-agent
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Server-side SDK for the MobileOPS Managed Agent API.
5
5
  Project-URL: Homepage, https://github.com/InnoMindX/deepagent/tree/main/managed-agent-sdk/python
6
6
  Project-URL: Repository, https://github.com/InnoMindX/deepagent
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mobileops-managed-agent"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Server-side SDK for the MobileOPS Managed Agent API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -65,6 +65,14 @@ from .vaults import (
65
65
  )
66
66
 
67
67
  __all__ = [
68
+ "ModelRetryEvent",
69
+ "ModelRetryTracker",
70
+ "MobileOpsArtifactsClient",
71
+ "MobileOpsStorageClient",
72
+ "Artifact",
73
+ "ArtifactFile",
74
+ "ArtifactJob",
75
+ "StorageUsage",
68
76
  "MobileOpsManagedAgentClient",
69
77
  "MobileOpsAgentClient",
70
78
  "MobileOpsFilesClient",
@@ -114,3 +122,16 @@ __all__ = [
114
122
  "CredentialVaultReference",
115
123
  "validate_credential_vault",
116
124
  ]
125
+
126
+ from .tool_permissions import validate_tool_permissions, TOOL_PERMISSIONS_CONFIG_KEY
127
+
128
+ from .artifacts import (
129
+ MobileOpsArtifactsClient,
130
+ MobileOpsStorageClient,
131
+ Artifact,
132
+ ArtifactFile,
133
+ ArtifactJob,
134
+ StorageUsage,
135
+ )
136
+
137
+ from .model_retry import ModelRetryEvent, ModelRetryTracker
@@ -0,0 +1,134 @@
1
+ """Persistent deliverables, independent of the sandbox and thread lifetime."""
2
+
3
+ from __future__ import annotations
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any, TypedDict, Literal
6
+ from urllib.parse import quote, urlencode
7
+ from .resources import MobileOpsResourcesClient, MobileOpsResourcesError
8
+
9
+
10
+ class ArtifactFile(TypedDict):
11
+ path: str
12
+ size: int
13
+ sha256: str
14
+
15
+
16
+ class Artifact(TypedDict):
17
+ id: str
18
+ thread_id: str
19
+ run_id: str
20
+ fingerprint: str
21
+ size_bytes: int
22
+ files: list[ArtifactFile]
23
+ created_at: str
24
+ expires_at: str
25
+
26
+
27
+ class ArtifactJob(TypedDict):
28
+ id: str
29
+ thread_id: str
30
+ run_id: str
31
+ status: Literal["pending", "downloading", "saved", "unchanged", "failed", "cancelled"]
32
+ artifact_id: str | None
33
+ error_code: str | None
34
+ error_message: str | None
35
+ error_status: int | None
36
+ created_at: str
37
+ expires_at: str
38
+ finished_at: str | None
39
+
40
+
41
+ class MobileOpsArtifactJobsClient:
42
+ def __init__(self, transport: MobileOpsResourcesClient, base: str):
43
+ self._transport = transport
44
+ self._base = base
45
+
46
+ async def list(self, *, thread_id: str | None = None, run_id: str | None = None, limit: int = 50, offset: int = 0) -> list[ArtifactJob]:
47
+ query = {"limit": str(limit), "offset": str(offset)}
48
+ if thread_id is not None:
49
+ query["thread_id"] = thread_id
50
+ if run_id is not None:
51
+ query["run_id"] = run_id
52
+ return (await self._transport._request_json("GET", self._base + "?" + urlencode(query)))["jobs"]
53
+
54
+ async def retrieve(self, job_id: str) -> ArtifactJob:
55
+ return (await self._transport._request_json("GET", self._base + "/" + quote(job_id, safe="")))["job"]
56
+
57
+
58
+ class StorageUsage(TypedDict):
59
+ used_bytes: int
60
+ reserved_bytes: int
61
+ limit_bytes: int | None
62
+ default_limit_bytes: int
63
+ effective_limit_bytes: int
64
+
65
+
66
+ class MobileOpsArtifactsClient:
67
+ def __init__(self, **options: Any):
68
+ self._transport = MobileOpsResourcesClient(**options)
69
+ self._base = options["api_url"].rstrip("/") + "/context-hub/v1/artifacts"
70
+ self.jobs = MobileOpsArtifactJobsClient(self._transport, options["api_url"].rstrip("/") + "/context-hub/v1/artifact-jobs")
71
+
72
+ async def list(
73
+ self,
74
+ *,
75
+ thread_id: str | None = None,
76
+ run_id: str | None = None,
77
+ limit: int = 50,
78
+ offset: int = 0,
79
+ ) -> list[Artifact]:
80
+ query = {"limit": str(limit), "offset": str(offset)}
81
+ if thread_id is not None:
82
+ query["thread_id"] = thread_id
83
+ if run_id is not None:
84
+ query["run_id"] = run_id
85
+ return (
86
+ await self._transport._request_json(
87
+ "GET", self._base + "?" + urlencode(query)
88
+ )
89
+ )["artifacts"]
90
+
91
+ async def retrieve(self, artifact_id: str) -> Artifact:
92
+ return (
93
+ await self._transport._request_json(
94
+ "GET", self._base + "/" + quote(artifact_id, safe="")
95
+ )
96
+ )["artifact"]
97
+
98
+ async def download(self, artifact_id: str) -> AsyncIterator[bytes]:
99
+ """Stream the ZIP without buffering the whole archive in the SDK."""
100
+ t = self._transport
101
+ async with t._client.stream(
102
+ "GET",
103
+ self._base + "/" + quote(artifact_id, safe="") + "/content",
104
+ headers=t._headers,
105
+ ) as response:
106
+ if response.is_error:
107
+ await response.aread()
108
+ try:
109
+ body = response.json()
110
+ except ValueError:
111
+ body = response.text
112
+ raise MobileOpsResourcesError(response, body)
113
+ async for chunk in response.aiter_bytes():
114
+ yield chunk
115
+
116
+ async def delete(self, artifact_id: str) -> None:
117
+ await self._transport._request(
118
+ "DELETE", self._base + "/" + quote(artifact_id, safe="")
119
+ )
120
+
121
+ async def aclose(self) -> None:
122
+ await self._transport.aclose()
123
+
124
+
125
+ class MobileOpsStorageClient:
126
+ def __init__(self, **options: Any):
127
+ self._transport = MobileOpsResourcesClient(**options)
128
+ self._url = options["api_url"].rstrip("/") + "/context-hub/v1/storage/usage"
129
+
130
+ async def usage(self) -> StorageUsage:
131
+ return await self._transport._request_json("GET", self._url)
132
+
133
+ async def aclose(self) -> None:
134
+ await self._transport.aclose()
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .tool_permissions import TOOL_PERMISSIONS_CONFIG_KEY, validate_tool_permissions
4
+
3
5
  from typing import Any, NotRequired, TypedDict
4
6
 
5
7
  from .capabilities import (
@@ -52,6 +54,7 @@ class MobileOpsAssistant(TypedDict, total=False):
52
54
  mcp_servers: dict[str, CapabilityMcpServer]
53
55
  response_schema: JsonSchema
54
56
  sandbox_profile: str
57
+ tool_permissions: dict[str, Any]
55
58
  credential_vault: str
56
59
  raw: Any
57
60
  created_at: str
@@ -77,7 +80,7 @@ def _map_hub(
77
80
  ) -> dict[str, Any]:
78
81
  entrypoints = item.get("entrypoints")
79
82
  if kind == "memory":
80
- if not entrypoints:
83
+ if "entrypoints" in item and not entrypoints:
81
84
  raise TypeError("memory attachments require a non-empty entrypoints list")
82
85
  elif entrypoints is not None:
83
86
  raise TypeError(
@@ -133,6 +136,7 @@ def to_wire_config(**kwargs: Any) -> MobileOpsAssistantConfig | None:
133
136
  and not has_response_schema
134
137
  and not has_sandbox_profile
135
138
  and not has_credential_vault
139
+ and "tool_permissions" not in kwargs
136
140
  ):
137
141
  return None
138
142
  configurable: dict[str, Any] = {}
@@ -153,6 +157,10 @@ def to_wire_config(**kwargs: Any) -> MobileOpsAssistantConfig | None:
153
157
  configurable[CREDENTIAL_VAULT_CONFIG_KEY] = validate_credential_vault(
154
158
  {"ref": credential_vault}
155
159
  )
160
+ if "tool_permissions" in kwargs:
161
+ configurable[TOOL_PERMISSIONS_CONFIG_KEY] = validate_tool_permissions(
162
+ kwargs["tool_permissions"], list((kwargs.get("mcp_servers") or {}).keys())
163
+ )
156
164
  return {"configurable": configurable} # type: ignore[return-value]
157
165
 
158
166
 
@@ -327,6 +335,10 @@ def from_wire_assistant(raw: object) -> MobileOpsAssistant:
327
335
  product["response_schema"] = response_schema
328
336
  if sandbox_profile is not None:
329
337
  product["sandbox_profile"] = sandbox_profile
338
+ if configurable and TOOL_PERMISSIONS_CONFIG_KEY in configurable:
339
+ product["tool_permissions"] = validate_tool_permissions(
340
+ configurable[TOOL_PERMISSIONS_CONFIG_KEY]
341
+ )
330
342
  if credential_vault is not None:
331
343
  product["credential_vault"] = credential_vault
332
344
  if isinstance(doc.get("name"), str):
@@ -52,6 +52,7 @@ class MobileOpsAssistantsClient:
52
52
  response_schema: JsonSchema | None = None,
53
53
  sandbox_profile: str | None = None,
54
54
  credential_vault: str | None = None,
55
+ tool_permissions: dict[str, Any] | _NotGiven = _NOT_GIVEN,
55
56
  assistant_id: str | None = None,
56
57
  if_exists: OnConflictBehavior | None = None,
57
58
  context: Context | None = None,
@@ -94,6 +95,8 @@ class MobileOpsAssistantsClient:
94
95
  if credential_vault is not None:
95
96
  product_kwargs["credential_vault"] = credential_vault
96
97
 
98
+ if not isinstance(tool_permissions, _NotGiven):
99
+ product_kwargs["tool_permissions"] = tool_permissions
97
100
  wire = to_wire_create_kwargs(**product_kwargs)
98
101
  native_kwargs = {
99
102
  key: wire[key]
@@ -172,6 +175,7 @@ class MobileOpsAssistantsClient:
172
175
  response_schema: JsonSchema | None | _NotGiven = _NOT_GIVEN,
173
176
  sandbox_profile: str | None | _NotGiven = _NOT_GIVEN,
174
177
  credential_vault: str | None | _NotGiven = _NOT_GIVEN,
178
+ tool_permissions: dict[str, Any] | _NotGiven = _NOT_GIVEN,
175
179
  context: Context | None = None,
176
180
  headers: Mapping[str, str] | None = None,
177
181
  params: QueryParamTypes | None = None,
@@ -212,7 +216,9 @@ class MobileOpsAssistantsClient:
212
216
  )
213
217
  ) or not isinstance(response_schema, _NotGiven) or not isinstance(
214
218
  sandbox_profile, _NotGiven
215
- ) or not isinstance(credential_vault, _NotGiven)
219
+ ) or not isinstance(credential_vault, _NotGiven) or not isinstance(
220
+ tool_permissions, _NotGiven
221
+ )
216
222
  if changes_config:
217
223
  current = await self.get(
218
224
  assistant_id,
@@ -248,6 +254,10 @@ class MobileOpsAssistantsClient:
248
254
  if not isinstance(sandbox_profile, _NotGiven)
249
255
  else current.get("sandbox_profile")
250
256
  )
257
+ if not isinstance(tool_permissions, _NotGiven):
258
+ product_kwargs["tool_permissions"] = tool_permissions
259
+ elif "tool_permissions" in current:
260
+ product_kwargs["tool_permissions"] = current["tool_permissions"]
251
261
  product_kwargs["credential_vault"] = (
252
262
  credential_vault
253
263
  if not isinstance(credential_vault, _NotGiven)
@@ -298,7 +298,7 @@ _SIZE_BUDGET_PROXY_HOST = f"{'x' * 253}:65535"
298
298
 
299
299
 
300
300
  def _size_budget_upstream_host(url: object) -> str | None:
301
- """Mirror backend auto-Host injection for tunnel absolute-form authority."""
301
+ """Mirror backend upstream Host configuration for tunneled targets."""
302
302
  if not isinstance(url, str) or not url.strip():
303
303
  return None
304
304
  from urllib.parse import urlparse
@@ -337,7 +337,7 @@ def _assert_mcp_gateway_config_launchable(servers: dict) -> None:
337
337
  continue
338
338
  # Budget processd backendTunnel + auto Host injected at sandbox setup.
339
339
  policies: dict = {
340
- "backendTunnel": {"proxy": {"host": _SIZE_BUDGET_PROXY_HOST}},
340
+ "backendTunnel": {"mode": "connect", "proxy": {"host": _SIZE_BUDGET_PROXY_HOST}},
341
341
  }
342
342
  headers = server.get("headers")
343
343
  header_set: dict = dict(headers) if isinstance(headers, dict) else {}
@@ -11,6 +11,7 @@ from .assistants import MobileOpsAssistantsClient
11
11
  from .resources import MobileOpsResourcesClient
12
12
  from .sandbox_profiles import MobileOpsSandboxProfilesClient
13
13
  from .vaults import MobileOpsVaultsClient
14
+ from .artifacts import MobileOpsArtifactsClient, MobileOpsStorageClient
14
15
 
15
16
 
16
17
  class MobileOpsAgentClient:
@@ -52,6 +53,9 @@ class MobileOpsManagedAgentClient:
52
53
  headers=merged_headers,
53
54
  )
54
55
  )
56
+ artifact_options = dict(api_url=api_url, api_key=api_key, headers=headers, http_client=http_client)
57
+ self.artifacts = MobileOpsArtifactsClient(**artifact_options)
58
+ self.storage = MobileOpsStorageClient(**artifact_options)
55
59
  self.files = MobileOpsFilesClient(
56
60
  api_url=api_url,
57
61
  api_key=api_key,
@@ -92,7 +96,13 @@ class MobileOpsManagedAgentClient:
92
96
  try:
93
97
  await self.vaults.aclose()
94
98
  finally:
95
- await self.sandbox_profiles.aclose()
99
+ try:
100
+ await self.sandbox_profiles.aclose()
101
+ finally:
102
+ try:
103
+ await self.artifacts.aclose()
104
+ finally:
105
+ await self.storage.aclose()
96
106
 
97
107
  async def __aenter__(self) -> "MobileOpsManagedAgentClient":
98
108
  return self
@@ -0,0 +1,64 @@
1
+ """Track custom model retry events while consuming the native Run stream."""
2
+
3
+ from __future__ import annotations
4
+ from collections.abc import Mapping, Sequence
5
+ from typing import Any, Literal, TypedDict
6
+
7
+
8
+ class ModelRetryEvent(TypedDict):
9
+ type: Literal["model_retry"]
10
+ call_id: str
11
+ namespace: str
12
+ phase: Literal["retrying", "resumed", "succeeded", "exhausted"]
13
+ attempt: int
14
+ max_retries: int
15
+ failed_message_ids: list[str]
16
+ delay_ms: int
17
+ reason: str | None
18
+
19
+
20
+ class ModelRetryTracker:
21
+ def __init__(self) -> None:
22
+ self._discarded: set[str] = set()
23
+ self._active: dict[str, ModelRetryEvent] = {}
24
+
25
+ def update(self, value: Any) -> ModelRetryEvent | None:
26
+ if not isinstance(value, dict) or value.get("type") != "model_retry":
27
+ return None
28
+ if (
29
+ not isinstance(value.get("call_id"), str)
30
+ or not isinstance(value.get("namespace"), str)
31
+ or value.get("phase")
32
+ not in ("retrying", "resumed", "succeeded", "exhausted")
33
+ or any(
34
+ type(value.get(k)) is not int or value[k] < 0
35
+ for k in ("attempt", "max_retries", "delay_ms")
36
+ )
37
+ or not isinstance(value.get("failed_message_ids"), list)
38
+ or not all(isinstance(i, str) for i in value["failed_message_ids"])
39
+ or "reason" not in value
40
+ or not (value.get("reason") is None or isinstance(value.get("reason"), str))
41
+ ):
42
+ return None
43
+ self._discarded.update(value["failed_message_ids"])
44
+ if value["phase"] in ("retrying", "resumed"):
45
+ self._active[value["call_id"]] = value
46
+ else:
47
+ self._active.pop(value["call_id"], None)
48
+ return value
49
+
50
+ @property
51
+ def pending(self) -> list[ModelRetryEvent]:
52
+ return list(self._active.values())
53
+
54
+ def filter(self, messages: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
55
+ return [m for m in messages if m.get("id") not in self._discarded]
56
+
57
+ def reset(self) -> None:
58
+ """Start tracking a different conversation."""
59
+ self._discarded.clear()
60
+ self._active.clear()
61
+
62
+ def finish(self) -> None:
63
+ """Clear status at Run end, keeping failed messages hidden."""
64
+ self._active.clear()
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from collections.abc import Mapping
4
5
  from typing import Any, NotRequired, TypedDict
5
6
  from urllib.parse import quote, urlencode
@@ -16,6 +17,8 @@ class ResourceVersion(TypedDict):
16
17
  ref: str
17
18
  file_count: NotRequired[int]
18
19
  created_at: NotRequired[str]
20
+ size_bytes: NotRequired[int]
21
+ files: NotRequired[list[ResourceFile]]
19
22
 
20
23
 
21
24
  class ResourceSummary(TypedDict):
@@ -65,10 +68,21 @@ class MobileOpsResourceVersionsClient:
65
68
  return payload["resource"]
66
69
 
67
70
  async def list(
68
- self, kind: CapabilityResourceKind, slug: str
71
+ self,
72
+ kind: CapabilityResourceKind,
73
+ slug: str,
74
+ *,
75
+ versions: list[str] | None = None,
76
+ include_files: bool = False,
69
77
  ) -> list[ResourceVersion]:
70
- resource = await self._resources.retrieve(kind, slug)
71
- return resource.get("versions", [])
78
+ params = {"include_files": str(include_files).lower()}
79
+ if versions is not None:
80
+ params["versions"] = json.dumps(versions)
81
+ payload = await self._resources._request_json(
82
+ "GET",
83
+ f"{self._resources._resource_url(kind, slug)}/versions?{urlencode(params)}",
84
+ )
85
+ return payload["versions"]
72
86
 
73
87
 
74
88
  class MobileOpsResourceFilesClient:
@@ -0,0 +1,97 @@
1
+ """Tool permission policies. Approval decisions never modify configured rules.
2
+ Keep validators aligned with the shared conformance fixtures.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import re
9
+ from typing import Any
10
+
11
+ TOOL_PERMISSIONS_CONFIG_KEY = "haiops_tool_permissions"
12
+ BUILTIN_TOOL_NAMES = frozenset(
13
+ {
14
+ "ls",
15
+ "read_file",
16
+ "write_file",
17
+ "edit_file",
18
+ "delete",
19
+ "glob",
20
+ "grep",
21
+ "execute",
22
+ "eval",
23
+ "task",
24
+ "write_todos",
25
+ "request_user_input",
26
+ }
27
+ )
28
+
29
+
30
+ def _object(value: Any, keys: set[str]) -> dict:
31
+ if not isinstance(value, dict) or set(value) - keys:
32
+ raise ValueError("Invalid tool permissions object or unsupported field")
33
+ return value
34
+
35
+
36
+ def _rule(value: Any, named: bool = False) -> dict:
37
+ r = _object(
38
+ value, {"enabled", "permission_policy"} | ({"name"} if named else set())
39
+ )
40
+ if "enabled" in r and type(r["enabled"]) is not bool:
41
+ raise ValueError("enabled must be boolean")
42
+ if "permission_policy" in r:
43
+ p = _object(r["permission_policy"], {"type"})
44
+ if p.get("type") not in ("always_allow", "always_ask"):
45
+ raise ValueError("Unsupported permission policy")
46
+ return r
47
+
48
+
49
+ def validate_tool_permissions(value: Any, servers: list[str] | None = None) -> dict:
50
+ p = _object(value, {"version", "toolsets"})
51
+ if (
52
+ type(p.get("version")) is not int
53
+ or p["version"] != 1
54
+ or not isinstance(p.get("toolsets"), list)
55
+ or len(p["toolsets"]) > 128
56
+ ):
57
+ raise ValueError(
58
+ "Tool permissions require version 1 and a toolsets array (max 128)"
59
+ )
60
+ seen = set()
61
+ for raw in p["toolsets"]:
62
+ s = _object(raw, {"type", "mcp_server_name", "default_config", "configs"})
63
+ if s.get("type") not in ("builtin", "mcp"):
64
+ raise ValueError("Unknown toolset type")
65
+ if s["type"] == "mcp":
66
+ server = s.get("mcp_server_name")
67
+ if (
68
+ not isinstance(server, str)
69
+ or not re.fullmatch(r"[A-Za-z0-9.-]+", server)
70
+ or (servers is not None and server not in servers)
71
+ ):
72
+ raise ValueError("Unknown or invalid MCP server")
73
+ elif "mcp_server_name" in s:
74
+ raise ValueError("Only MCP toolsets accept mcp_server_name")
75
+ key = (s["type"], s.get("mcp_server_name"))
76
+ if key in seen:
77
+ raise ValueError("Duplicate toolset")
78
+ seen.add(key)
79
+ if "default_config" in s:
80
+ _rule(s["default_config"])
81
+ if "configs" in s:
82
+ if not isinstance(s["configs"], list) or len(s["configs"]) > 1024:
83
+ raise ValueError("configs must be an array (max 1024)")
84
+ names = set()
85
+ for raw_config in s["configs"]:
86
+ c = _rule(raw_config, True)
87
+ name = c.get("name")
88
+ if (
89
+ not isinstance(name, str)
90
+ or not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", name)
91
+ or name in names
92
+ ):
93
+ raise ValueError("Invalid or duplicate tool name")
94
+ if s["type"] == "builtin" and name not in BUILTIN_TOOL_NAMES:
95
+ raise ValueError("Unknown built-in tool")
96
+ names.add(name)
97
+ return copy.deepcopy(p)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mobileops-managed-agent
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Server-side SDK for the MobileOPS Managed Agent API.
5
5
  Project-URL: Homepage, https://github.com/InnoMindX/deepagent/tree/main/managed-agent-sdk/python
6
6
  Project-URL: Repository, https://github.com/InnoMindX/deepagent
@@ -2,6 +2,7 @@ MANIFEST.in
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/mobileops_managed_agent/__init__.py
5
+ src/mobileops_managed_agent/artifacts.py
5
6
  src/mobileops_managed_agent/assistant_model.py
6
7
  src/mobileops_managed_agent/assistants.py
7
8
  src/mobileops_managed_agent/auth.py
@@ -9,10 +10,12 @@ src/mobileops_managed_agent/capabilities.py
9
10
  src/mobileops_managed_agent/client.py
10
11
  src/mobileops_managed_agent/credential_vault.py
11
12
  src/mobileops_managed_agent/files.py
13
+ src/mobileops_managed_agent/model_retry.py
12
14
  src/mobileops_managed_agent/resources.py
13
15
  src/mobileops_managed_agent/response_schema.py
14
16
  src/mobileops_managed_agent/sandbox_profile.py
15
17
  src/mobileops_managed_agent/sandbox_profiles.py
18
+ src/mobileops_managed_agent/tool_permissions.py
16
19
  src/mobileops_managed_agent/vaults.py
17
20
  src/mobileops_managed_agent.egg-info/PKG-INFO
18
21
  src/mobileops_managed_agent.egg-info/SOURCES.txt