acontext 0.0.13__tar.gz → 0.0.16__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 (41) hide show
  1. {acontext-0.0.13 → acontext-0.0.16}/PKG-INFO +1 -1
  2. {acontext-0.0.13 → acontext-0.0.16}/pyproject.toml +1 -1
  3. acontext-0.0.16/src/acontext/agent/__init__.py +10 -0
  4. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/agent/disk.py +71 -8
  5. acontext-0.0.16/src/acontext/agent/skill.py +148 -0
  6. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/async_client.py +4 -0
  7. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/client.py +4 -0
  8. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/__init__.py +8 -0
  9. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/async_disks.py +11 -3
  10. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/async_sessions.py +36 -16
  11. acontext-0.0.16/src/acontext/resources/async_skills.py +150 -0
  12. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/async_spaces.py +12 -2
  13. acontext-0.0.16/src/acontext/resources/async_users.py +20 -0
  14. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/disks.py +11 -3
  15. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/sessions.py +17 -0
  16. acontext-0.0.16/src/acontext/resources/skills.py +146 -0
  17. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/spaces.py +12 -2
  18. acontext-0.0.16/src/acontext/resources/users.py +20 -0
  19. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/types/__init__.py +14 -1
  20. acontext-0.0.16/src/acontext/types/common.py +11 -0
  21. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/types/disk.py +3 -7
  22. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/types/session.py +31 -15
  23. acontext-0.0.16/src/acontext/types/skill.py +69 -0
  24. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/types/space.py +1 -0
  25. acontext-0.0.13/src/acontext/agent/__init__.py +0 -0
  26. {acontext-0.0.13 → acontext-0.0.16}/README.md +0 -0
  27. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/__init__.py +0 -0
  28. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/_constants.py +0 -0
  29. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/_utils.py +0 -0
  30. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/agent/base.py +0 -0
  31. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/client_types.py +0 -0
  32. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/errors.py +0 -0
  33. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/messages.py +0 -0
  34. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/py.typed +0 -0
  35. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/async_blocks.py +0 -0
  36. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/async_tools.py +0 -0
  37. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/blocks.py +0 -0
  38. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/resources/tools.py +0 -0
  39. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/types/block.py +0 -0
  40. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/types/tool.py +0 -0
  41. {acontext-0.0.13 → acontext-0.0.16}/src/acontext/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acontext
3
- Version: 0.0.13
3
+ Version: 0.0.16
4
4
  Summary: Python SDK for the Acontext API
5
5
  Keywords: acontext,sdk,client,api
6
6
  Requires-Dist: httpx>=0.28.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acontext"
3
- version = "0.0.13"
3
+ version = "0.0.16"
4
4
  description = "Python SDK for the Acontext API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,10 @@
1
+ """Agent tools for LLM function calling."""
2
+
3
+ from .disk import DISK_TOOLS
4
+ from .skill import SKILL_TOOLS
5
+
6
+ __all__ = [
7
+ "DISK_TOOLS",
8
+ "SKILL_TOOLS",
9
+ ]
10
+
@@ -261,15 +261,71 @@ class ListTool(BaseTool):
261
261
  if not artifacts_list and not result.directories:
262
262
  return f"No files or directories found in '{normalized_path}'"
263
263
 
264
- output_parts = []
265
- if artifacts_list:
266
- output_parts.append(f"Files: {', '.join(artifacts_list)}")
267
- if result.directories:
268
- output_parts.append(f"Directories: {', '.join(result.directories)}")
269
-
270
- ls_sect = "\n".join(output_parts)
264
+ file_sect = "\n".join(artifacts_list) or "[NO FILE]"
265
+ dir_sect = (
266
+ "\n".join([d.rstrip("/") + "/" for d in result.directories]) or "[NO DIR]"
267
+ )
271
268
  return f"""[Listing in {normalized_path}]
272
- {ls_sect}"""
269
+ Directories:
270
+ {dir_sect}
271
+ Files:
272
+ {file_sect}"""
273
+
274
+
275
+ class DownloadFileTool(BaseTool):
276
+ """Tool for getting a public download URL for a file on the Acontext disk."""
277
+
278
+ @property
279
+ def name(self) -> str:
280
+ return "download_file"
281
+
282
+ @property
283
+ def description(self) -> str:
284
+ return "Get a public URL to download a file. Returns a presigned URL that can be shared or used to access the file."
285
+
286
+ @property
287
+ def arguments(self) -> dict:
288
+ return {
289
+ "file_path": {
290
+ "type": "string",
291
+ "description": "Optional directory path where the file is located, e.g. '/notes/'. Defaults to root '/' if not specified.",
292
+ },
293
+ "filename": {
294
+ "type": "string",
295
+ "description": "Filename to get the download URL for.",
296
+ },
297
+ "expire": {
298
+ "type": "integer",
299
+ "description": "URL expiration time in seconds. Defaults to 3600 (1 hour).",
300
+ },
301
+ }
302
+
303
+ @property
304
+ def required_arguments(self) -> list[str]:
305
+ return ["filename"]
306
+
307
+ def execute(self, ctx: DiskContext, llm_arguments: dict) -> str:
308
+ """Get a public download URL for a file."""
309
+ filename = llm_arguments.get("filename")
310
+ file_path = llm_arguments.get("file_path")
311
+ expire = llm_arguments.get("expire", 3600)
312
+
313
+ if not filename:
314
+ raise ValueError("filename is required")
315
+
316
+ normalized_path = _normalize_path(file_path)
317
+ result = ctx.client.disks.artifacts.get(
318
+ ctx.disk_id,
319
+ file_path=normalized_path,
320
+ filename=filename,
321
+ with_public_url=True,
322
+ expire=expire,
323
+ )
324
+
325
+ if not result.public_url:
326
+ raise RuntimeError("Failed to get public URL: server did not return a URL.")
327
+
328
+ return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
273
329
 
274
330
 
275
331
  class DiskToolPool(BaseToolPool):
@@ -284,6 +340,7 @@ DISK_TOOLS.add_tool(WriteFileTool())
284
340
  DISK_TOOLS.add_tool(ReadFileTool())
285
341
  DISK_TOOLS.add_tool(ReplaceStringTool())
286
342
  DISK_TOOLS.add_tool(ListTool())
343
+ DISK_TOOLS.add_tool(DownloadFileTool())
287
344
 
288
345
 
289
346
  if __name__ == "__main__":
@@ -323,3 +380,9 @@ if __name__ == "__main__":
323
380
  ctx, "read_file", {"filename": "test.txt", "file_path": "/try/"}
324
381
  )
325
382
  print(r)
383
+ r = DISK_TOOLS.execute_tool(
384
+ ctx,
385
+ "download_file",
386
+ {"filename": "test.txt", "file_path": "/try/", "expire": 300},
387
+ )
388
+ print(r)
@@ -0,0 +1,148 @@
1
+ """
2
+ Skill tools for agent operations.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .base import BaseContext, BaseTool, BaseToolPool
8
+ from ..client import AcontextClient
9
+
10
+
11
+ @dataclass
12
+ class SkillContext(BaseContext):
13
+ client: AcontextClient
14
+
15
+
16
+ class GetSkillTool(BaseTool):
17
+ """Tool for getting a skill by name."""
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ return "get_skill"
22
+
23
+ @property
24
+ def description(self) -> str:
25
+ return (
26
+ "Get a skill by its name. Return the skill information including the relative paths of the files and their mime type categories"
27
+ )
28
+
29
+ @property
30
+ def arguments(self) -> dict:
31
+ return {
32
+ "name": {
33
+ "type": "string",
34
+ "description": "The name of the skill (unique within project).",
35
+ },
36
+ }
37
+
38
+ @property
39
+ def required_arguments(self) -> list[str]:
40
+ return ["name"]
41
+
42
+ def execute(self, ctx: SkillContext, llm_arguments: dict) -> str:
43
+ """Get a skill by name."""
44
+ name = llm_arguments.get("name")
45
+
46
+ if not name:
47
+ raise ValueError("name is required")
48
+
49
+ skill = ctx.client.skills.get_by_name(name)
50
+
51
+ file_count = len(skill.file_index)
52
+
53
+ # Format all files with path and MIME type
54
+ if skill.file_index:
55
+ file_list = "\n".join(
56
+ [f" - {file_info.path} ({file_info.mime})" for file_info in skill.file_index]
57
+ )
58
+ else:
59
+ file_list = " [NO FILES]"
60
+
61
+ return (
62
+ f"Skill: {skill.name} (ID: {skill.id})\n"
63
+ f"Description: {skill.description}\n"
64
+ f"Files: {file_count} file(s)\n"
65
+ f"{file_list}\n"
66
+ f"Created: {skill.created_at}\n"
67
+ f"Updated: {skill.updated_at}"
68
+ )
69
+
70
+
71
+ class GetSkillFileTool(BaseTool):
72
+ """Tool for getting a file from a skill."""
73
+
74
+ @property
75
+ def name(self) -> str:
76
+ return "get_skill_file"
77
+
78
+ @property
79
+ def description(self) -> str:
80
+ return (
81
+ "Get a file from a skill by name. The file_path should be a relative path within the skill (e.g., 'scripts/extract_text.json'). "
82
+ )
83
+
84
+ @property
85
+ def arguments(self) -> dict:
86
+ return {
87
+ "skill_name": {
88
+ "type": "string",
89
+ "description": "The name of the skill.",
90
+ },
91
+ "file_path": {
92
+ "type": "string",
93
+ "description": "Relative path to the file within the skill (e.g., 'scripts/extract_text.json').",
94
+ },
95
+ "expire": {
96
+ "type": "integer",
97
+ "description": "URL expiration time in seconds (only used for non-parseable files). Defaults to 900 (15 minutes).",
98
+ },
99
+ }
100
+
101
+ @property
102
+ def required_arguments(self) -> list[str]:
103
+ return ["skill_name", "file_path"]
104
+
105
+ def execute(self, ctx: SkillContext, llm_arguments: dict) -> str:
106
+ """Get a skill file."""
107
+ skill_name = llm_arguments.get("skill_name")
108
+ file_path = llm_arguments.get("file_path")
109
+ expire = llm_arguments.get("expire")
110
+
111
+ if not file_path:
112
+ raise ValueError("file_path is required")
113
+ if not skill_name:
114
+ raise ValueError("skill_name is required")
115
+
116
+ result = ctx.client.skills.get_file_by_name(
117
+ skill_name=skill_name,
118
+ file_path=file_path,
119
+ expire=expire,
120
+ )
121
+
122
+ output_parts = [f"File '{result.path}' (MIME: {result.mime}) from skill '{skill_name}':"]
123
+
124
+ if result.content:
125
+ output_parts.append(f"\nContent (type: {result.content.type}):")
126
+ output_parts.append(result.content.raw)
127
+
128
+ if result.url:
129
+ expire_seconds = expire if expire is not None else 900
130
+ output_parts.append(f"\nDownload URL (expires in {expire_seconds} seconds):")
131
+ output_parts.append(result.url)
132
+
133
+ if not result.content and not result.url:
134
+ return f"File '{result.path}' retrieved but no content or URL returned."
135
+
136
+ return "\n".join(output_parts)
137
+
138
+
139
+ class SkillToolPool(BaseToolPool):
140
+ """Tool pool for skill operations on Acontext skills."""
141
+
142
+ def format_context(self, client: AcontextClient) -> SkillContext:
143
+ return SkillContext(client=client)
144
+
145
+
146
+ SKILL_TOOLS = SkillToolPool()
147
+ SKILL_TOOLS.add_tool(GetSkillTool())
148
+ SKILL_TOOLS.add_tool(GetSkillFileTool())
@@ -17,6 +17,8 @@ from .resources.async_blocks import AsyncBlocksAPI as AsyncBlocksAPI
17
17
  from .resources.async_sessions import AsyncSessionsAPI as AsyncSessionsAPI
18
18
  from .resources.async_spaces import AsyncSpacesAPI as AsyncSpacesAPI
19
19
  from .resources.async_tools import AsyncToolsAPI as AsyncToolsAPI
20
+ from .resources.async_skills import AsyncSkillsAPI as AsyncSkillsAPI
21
+ from .resources.async_users import AsyncUsersAPI as AsyncUsersAPI
20
22
 
21
23
 
22
24
  class AcontextAsyncClient:
@@ -109,6 +111,8 @@ class AcontextAsyncClient:
109
111
  self.artifacts = self.disks.artifacts
110
112
  self.blocks = AsyncBlocksAPI(self)
111
113
  self.tools = AsyncToolsAPI(self)
114
+ self.skills = AsyncSkillsAPI(self)
115
+ self.users = AsyncUsersAPI(self)
112
116
 
113
117
  @property
114
118
  def base_url(self) -> str:
@@ -17,6 +17,8 @@ from .resources.blocks import BlocksAPI as BlocksAPI
17
17
  from .resources.sessions import SessionsAPI as SessionsAPI
18
18
  from .resources.spaces import SpacesAPI as SpacesAPI
19
19
  from .resources.tools import ToolsAPI as ToolsAPI
20
+ from .resources.skills import SkillsAPI as SkillsAPI
21
+ from .resources.users import UsersAPI as UsersAPI
20
22
 
21
23
 
22
24
  class AcontextClient:
@@ -109,6 +111,8 @@ class AcontextClient:
109
111
  self.artifacts = self.disks.artifacts
110
112
  self.blocks = BlocksAPI(self)
111
113
  self.tools = ToolsAPI(self)
114
+ self.skills = SkillsAPI(self)
115
+ self.users = UsersAPI(self)
112
116
 
113
117
  @property
114
118
  def base_url(self) -> str:
@@ -5,11 +5,15 @@ from .async_disks import AsyncDisksAPI, AsyncDiskArtifactsAPI
5
5
  from .async_sessions import AsyncSessionsAPI
6
6
  from .async_spaces import AsyncSpacesAPI
7
7
  from .async_tools import AsyncToolsAPI
8
+ from .async_skills import AsyncSkillsAPI
9
+ from .async_users import AsyncUsersAPI
8
10
  from .blocks import BlocksAPI
9
11
  from .disks import DisksAPI, DiskArtifactsAPI
10
12
  from .sessions import SessionsAPI
11
13
  from .spaces import SpacesAPI
12
14
  from .tools import ToolsAPI
15
+ from .skills import SkillsAPI
16
+ from .users import UsersAPI
13
17
 
14
18
  __all__ = [
15
19
  "DisksAPI",
@@ -18,10 +22,14 @@ __all__ = [
18
22
  "SessionsAPI",
19
23
  "SpacesAPI",
20
24
  "ToolsAPI",
25
+ "SkillsAPI",
26
+ "UsersAPI",
21
27
  "AsyncDisksAPI",
22
28
  "AsyncDiskArtifactsAPI",
23
29
  "AsyncBlocksAPI",
24
30
  "AsyncSessionsAPI",
25
31
  "AsyncSpacesAPI",
26
32
  "AsyncToolsAPI",
33
+ "AsyncSkillsAPI",
34
+ "AsyncUsersAPI",
27
35
  ]
@@ -27,6 +27,7 @@ class AsyncDisksAPI:
27
27
  async def list(
28
28
  self,
29
29
  *,
30
+ user: str | None = None,
30
31
  limit: int | None = None,
31
32
  cursor: str | None = None,
32
33
  time_desc: bool | None = None,
@@ -34,6 +35,7 @@ class AsyncDisksAPI:
34
35
  """List all disks in the project.
35
36
 
36
37
  Args:
38
+ user: Filter by user identifier. Defaults to None.
37
39
  limit: Maximum number of disks to return. Defaults to None.
38
40
  cursor: Cursor for pagination. Defaults to None.
39
41
  time_desc: Order by created_at descending if True, ascending if False. Defaults to None.
@@ -41,17 +43,23 @@ class AsyncDisksAPI:
41
43
  Returns:
42
44
  ListDisksOutput containing the list of disks and pagination information.
43
45
  """
44
- params = build_params(limit=limit, cursor=cursor, time_desc=time_desc)
46
+ params = build_params(user=user, limit=limit, cursor=cursor, time_desc=time_desc)
45
47
  data = await self._requester.request("GET", "/disk", params=params or None)
46
48
  return ListDisksOutput.model_validate(data)
47
49
 
48
- async def create(self) -> Disk:
50
+ async def create(self, *, user: str | None = None) -> Disk:
49
51
  """Create a new disk.
50
52
 
53
+ Args:
54
+ user: Optional user identifier string. Defaults to None.
55
+
51
56
  Returns:
52
57
  The created Disk object.
53
58
  """
54
- data = await self._requester.request("POST", "/disk")
59
+ payload: dict[str, Any] = {}
60
+ if user is not None:
61
+ payload["user"] = user
62
+ data = await self._requester.request("POST", "/disk", json_data=payload or None)
55
63
  return Disk.model_validate(data)
56
64
 
57
65
  async def delete(self, disk_id: str) -> None:
@@ -37,6 +37,7 @@ class AsyncSessionsAPI:
37
37
  async def list(
38
38
  self,
39
39
  *,
40
+ user: str | None = None,
40
41
  space_id: str | None = None,
41
42
  not_connected: bool | None = None,
42
43
  limit: int | None = None,
@@ -46,6 +47,7 @@ class AsyncSessionsAPI:
46
47
  """List all sessions in the project.
47
48
 
48
49
  Args:
50
+ user: Filter by user identifier. Defaults to None.
49
51
  space_id: Filter sessions by space ID. Defaults to None.
50
52
  not_connected: Filter sessions that are not connected to a space. Defaults to None.
51
53
  limit: Maximum number of sessions to return. Defaults to None.
@@ -56,6 +58,8 @@ class AsyncSessionsAPI:
56
58
  ListSessionsOutput containing the list of sessions and pagination information.
57
59
  """
58
60
  params: dict[str, Any] = {}
61
+ if user:
62
+ params["user"] = user
59
63
  if space_id:
60
64
  params["space_id"] = space_id
61
65
  params.update(
@@ -72,12 +76,14 @@ class AsyncSessionsAPI:
72
76
  async def create(
73
77
  self,
74
78
  *,
79
+ user: str | None = None,
75
80
  space_id: str | None = None,
76
81
  configs: Mapping[str, Any] | None = None,
77
82
  ) -> Session:
78
83
  """Create a new session.
79
84
 
80
85
  Args:
86
+ user: Optional user identifier string. Defaults to None.
81
87
  space_id: Optional space ID to associate with the session. Defaults to None.
82
88
  configs: Optional session configuration dictionary. Defaults to None.
83
89
 
@@ -85,6 +91,8 @@ class AsyncSessionsAPI:
85
91
  The created Session object.
86
92
  """
87
93
  payload: dict[str, Any] = {}
94
+ if user:
95
+ payload["user"] = user
88
96
  if space_id:
89
97
  payload["space_id"] = space_id
90
98
  if configs is not None:
@@ -267,6 +275,7 @@ class AsyncSessionsAPI:
267
275
  format: Literal["acontext", "openai", "anthropic", "gemini"] = "openai",
268
276
  time_desc: bool | None = None,
269
277
  edit_strategies: Optional[List[EditStrategy]] = None,
278
+ pin_editing_strategies_at_message: str | None = None,
270
279
  ) -> GetMessagesOutput:
271
280
  """Get messages for a session.
272
281
 
@@ -283,6 +292,12 @@ class AsyncSessionsAPI:
283
292
  - Remove tool results: [{"type": "remove_tool_result", "params": {"keep_recent_n_tool_results": 3}}]
284
293
  - Token limit: [{"type": "token_limit", "params": {"limit_tokens": 20000}}]
285
294
  Defaults to None.
295
+ pin_editing_strategies_at_message: Message ID to pin editing strategies at.
296
+ When provided, strategies are only applied to messages up to and including
297
+ this message ID, keeping subsequent messages unchanged. This helps maintain
298
+ prompt cache stability by preserving a stable prefix. The response includes
299
+ edit_at_message_id indicating where strategies were applied. Pass this value
300
+ in subsequent requests to maintain cache hits. Defaults to None.
286
301
 
287
302
  Returns:
288
303
  GetMessagesOutput containing the list of messages and pagination information.
@@ -300,6 +315,10 @@ class AsyncSessionsAPI:
300
315
  )
301
316
  if edit_strategies is not None:
302
317
  params["edit_strategies"] = json.dumps(edit_strategies)
318
+ if pin_editing_strategies_at_message is not None:
319
+ params["pin_editing_strategies_at_message"] = (
320
+ pin_editing_strategies_at_message
321
+ )
303
322
  data = await self._requester.request(
304
323
  "GET", f"/session/{session_id}/messages", params=params or None
305
324
  )
@@ -348,20 +367,21 @@ class AsyncSessionsAPI:
348
367
  )
349
368
  return TokenCounts.model_validate(data)
350
369
 
370
+
351
371
  async def messages_observing_status(self, session_id: str) -> MessageObservingStatus:
352
- """Get message observing status counts for a session.
353
-
354
- Returns the count of messages by their observing status:
355
- observed, in_process, and pending.
356
-
357
- Args:
358
- session_id: The UUID of the session.
359
-
360
- Returns:
361
- MessageObservingStatus object containing observed, in_process,
362
- pending counts and updated_at timestamp.
363
- """
364
- data = await self._requester.request(
365
- "GET", f"/session/{session_id}/observing_status"
366
- )
367
- return MessageObservingStatus.model_validate(data)
372
+ """Get message observing status counts for a session.
373
+
374
+ Returns the count of messages by their observing status:
375
+ observed, in_process, and pending.
376
+
377
+ Args:
378
+ session_id: The UUID of the session.
379
+
380
+ Returns:
381
+ MessageObservingStatus object containing observed, in_process,
382
+ pending counts and updated_at timestamp.
383
+ """
384
+ data = await self._requester.request(
385
+ "GET", f"/session/{session_id}/observing_status"
386
+ )
387
+ return MessageObservingStatus.model_validate(data)
@@ -0,0 +1,150 @@
1
+ """
2
+ Skills endpoints (async).
3
+ """
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import Any, BinaryIO, cast
8
+
9
+ from .._utils import build_params
10
+ from ..client_types import AsyncRequesterProtocol
11
+ from ..types.skill import (
12
+ GetSkillFileResp,
13
+ ListSkillsOutput,
14
+ Skill,
15
+ SkillCatalogItem,
16
+ _ListSkillsResponse,
17
+ )
18
+ from ..uploads import FileUpload, normalize_file_upload
19
+
20
+
21
+ class AsyncSkillsAPI:
22
+ def __init__(self, requester: AsyncRequesterProtocol) -> None:
23
+ self._requester = requester
24
+
25
+ async def create(
26
+ self,
27
+ *,
28
+ file: FileUpload
29
+ | tuple[str, BinaryIO | bytes]
30
+ | tuple[str, BinaryIO | bytes, str],
31
+ user: str | None = None,
32
+ meta: Mapping[str, Any] | None = None,
33
+ ) -> Skill:
34
+ """Create a new skill by uploading a ZIP file.
35
+
36
+ The ZIP file must contain a SKILL.md file (case-insensitive) with YAML format
37
+ containing 'name' and 'description' fields.
38
+
39
+ Args:
40
+ file: The ZIP file to upload (FileUpload object or tuple format).
41
+ user: Optional user identifier string. Defaults to None.
42
+ meta: Custom metadata as JSON-serializable dict, defaults to None.
43
+
44
+ Returns:
45
+ Skill containing the created skill information.
46
+ """
47
+ upload = normalize_file_upload(file)
48
+ files = {"file": upload.as_httpx()}
49
+ form: dict[str, Any] = {}
50
+ if user is not None:
51
+ form["user"] = user
52
+ if meta is not None:
53
+ form["meta"] = json.dumps(cast(Mapping[str, Any], meta))
54
+ data = await self._requester.request(
55
+ "POST",
56
+ "/agent_skills",
57
+ data=form or None,
58
+ files=files,
59
+ )
60
+ return Skill.model_validate(data)
61
+
62
+ async def list_catalog(
63
+ self,
64
+ *,
65
+ user: str | None = None,
66
+ limit: int | None = None,
67
+ cursor: str | None = None,
68
+ time_desc: bool | None = None,
69
+ ) -> ListSkillsOutput:
70
+ """Get a catalog of skills (names and descriptions only) with pagination.
71
+
72
+ Args:
73
+ user: Filter by user identifier. Defaults to None.
74
+ limit: Maximum number of skills per page (defaults to 100, max 200).
75
+ cursor: Cursor for pagination to fetch the next page (optional).
76
+ time_desc: Order by created_at descending if True, ascending if False (defaults to False).
77
+
78
+ Returns:
79
+ ListSkillsOutput containing skills with name and description for the current page,
80
+ along with pagination information (next_cursor and has_more).
81
+ """
82
+ effective_limit = limit if limit is not None else 100
83
+ params = build_params(user=user, limit=effective_limit, cursor=cursor, time_desc=time_desc)
84
+ data = await self._requester.request(
85
+ "GET", "/agent_skills", params=params or None
86
+ )
87
+ api_response = _ListSkillsResponse.model_validate(data)
88
+
89
+ # Convert to catalog format (name and description only)
90
+ return ListSkillsOutput(
91
+ items=[
92
+ SkillCatalogItem(name=skill.name, description=skill.description)
93
+ for skill in api_response.items
94
+ ],
95
+ next_cursor=api_response.next_cursor,
96
+ has_more=api_response.has_more,
97
+ )
98
+
99
+ async def get_by_name(self, name: str) -> Skill:
100
+ """Get a skill by its name.
101
+
102
+ Args:
103
+ name: The name of the skill (unique within project).
104
+
105
+ Returns:
106
+ Skill containing the skill information.
107
+ """
108
+ params = {"name": name}
109
+ data = await self._requester.request(
110
+ "GET", "/agent_skills/by_name", params=params
111
+ )
112
+ return Skill.model_validate(data)
113
+
114
+ async def delete(self, skill_id: str) -> None:
115
+ """Delete a skill by its ID.
116
+
117
+ Args:
118
+ skill_id: The UUID of the skill to delete.
119
+ """
120
+ await self._requester.request("DELETE", f"/agent_skills/{skill_id}")
121
+
122
+ async def get_file_by_name(
123
+ self,
124
+ *,
125
+ skill_name: str,
126
+ file_path: str,
127
+ expire: int | None = None,
128
+ ) -> GetSkillFileResp:
129
+ """Get a file from a skill by name.
130
+
131
+ The backend automatically returns content for parseable text files, or a presigned URL
132
+ for non-parseable files (binary, images, etc.).
133
+
134
+ Args:
135
+ skill_name: The name of the skill.
136
+ file_path: Relative path to the file within the skill (e.g., 'scripts/extract_text.json').
137
+ expire: URL expiration time in seconds. Defaults to 900 (15 minutes).
138
+
139
+ Returns:
140
+ GetSkillFileResp containing the file path, MIME type, and either content or URL.
141
+ """
142
+ endpoint = f"/agent_skills/by_name/{skill_name}/file"
143
+
144
+ params = {"file_path": file_path}
145
+ if expire is not None:
146
+ params["expire"] = expire
147
+
148
+ data = await self._requester.request("GET", endpoint, params=params)
149
+ return GetSkillFileResp.model_validate(data)
150
+