diff --git a/agent/python/agent_protocol/agent.py b/agent/python/agent_protocol/agent.py index a3440d7..130c29b 100644 --- a/agent/python/agent_protocol/agent.py +++ b/agent/python/agent_protocol/agent.py @@ -1,7 +1,8 @@ import asyncio import os -from fastapi import APIRouter +import aiofiles +from fastapi import APIRouter, UploadFile from fastapi.responses import FileResponse from hypercorn.asyncio import serve from hypercorn.config import Config @@ -100,10 +101,6 @@ async def execute_agent_task_step( step = await _step_handler(step) step.status = Status.completed - - if step.artifacts: - task.artifacts.extend(step.artifacts) - return step @@ -132,6 +129,31 @@ async def list_agent_task_artifacts(task_id: str) -> List[Artifact]: return task.artifacts +@base_router.post( + "/agent/tasks/{task_id}/artifacts", + response_model=Artifact, + tags=["agent"], +) +async def upload_agent_task_artifacts( + task_id: str, file: UploadFile, relative_path: Optional[str] = None +) -> Artifact: + """ + Upload an artifact for the specified task. + """ + await Agent.db.get_task(task_id) + artifact = await Agent.db.create_artifact(task_id, file.filename, relative_path) + + path = Agent.get_artifact_folder(task_id, artifact) + if not os.path.exists(path): + os.makedirs(path) + + async with aiofiles.open(os.path.join(path, file.filename), "wb") as f: + while content := await file.read(1024 * 1024): # async read chunk ~1MiB + await f.write(content) + + return artifact + + @base_router.get( "/agent/tasks/{task_id}/artifacts/{artifact_id}", tags=["agent"], @@ -172,13 +194,20 @@ def get_workspace(task_id: str) -> str: return os.path.join(os.getcwd(), Agent.workspace, task_id) @staticmethod - def get_artifact_path(task_id: str, artifact: Artifact) -> str: + def get_artifact_folder(task_id: str, artifact: Artifact) -> str: """ Get the artifact path for the specified task and artifact. """ workspace_path = Agent.get_workspace(task_id) relative_path = artifact.relative_path or "" - return os.path.join(workspace_path, relative_path, artifact.file_name) + return os.path.join(workspace_path, relative_path) + + @staticmethod + def get_artifact_path(task_id: str, artifact: Artifact) -> str: + """ + Get the artifact path for the specified task and artifact. + """ + return os.path.join(Agent.get_artifact_folder(task_id, artifact), artifact.file_name) @staticmethod def start(port: int = 8000, router: APIRouter = base_router): diff --git a/agent/python/agent_protocol/db.py b/agent/python/agent_protocol/db.py index a6aa2a7..e48ce49 100644 --- a/agent/python/agent_protocol/db.py +++ b/agent/python/agent_protocol/db.py @@ -31,6 +31,15 @@ async def create_step( ) -> Step: raise NotImplementedError + async def create_artifact( + self, + task_id: str, + file_name: str, + relative_path: Optional[str] = None, + step_id: Optional[str] = None, + ) -> Artifact: + raise NotImplementedError + async def get_task(self, task_id: str) -> Task: raise NotImplementedError @@ -114,6 +123,26 @@ async def get_artifact(self, task_id: str, artifact_id: str) -> Artifact: raise Exception(f"Artifact with id {artifact_id} not found") return artifact + async def create_artifact( + self, + task_id: str, + file_name: str, + relative_path: Optional[str] = None, + step_id: Optional[str] = None, + ) -> Artifact: + artifact_id = str(uuid.uuid4()) + artifact = Artifact( + artifact_id=artifact_id, file_name=file_name, relative_path=relative_path + ) + task = await self.get_task(task_id) + task.artifacts.append(artifact) + + if step_id: + step = await self.get_step(task_id, step_id) + step.artifacts.append(artifact) + + return artifact + async def list_tasks(self) -> List[Task]: return [task for task in self._tasks.values()] diff --git a/agent/python/agent_protocol/models.py b/agent/python/agent_protocol/models.py index 306c329..0214b9b 100644 --- a/agent/python/agent_protocol/models.py +++ b/agent/python/agent_protocol/models.py @@ -24,6 +24,13 @@ class Artifact(BaseModel): ) +class ArtifactUpload(BaseModel): + file: bytes = Field(..., description="File to upload.") + relative_path: Optional[str] = Field( + None, description="Relative path of the artifact in the agent's workspace." + ) + + class StepInput(BaseModel): __root__: Any = Field( ..., description="Input parameters for the task step. Any value is allowed." diff --git a/agent/python/agent_protocol/router.py b/agent/python/agent_protocol/router.py deleted file mode 100644 index e69de29..0000000 diff --git a/agent/python/examples/smol_developer.py b/agent/python/examples/smol_developer.py index e49a921..edc3ae5 100644 --- a/agent/python/examples/smol_developer.py +++ b/agent/python/examples/smol_developer.py @@ -61,16 +61,17 @@ async def _generate_code(task: Task, step: Step) -> Step: file_path = step.additional_properties["file_path"] code = await generate_code(task.input, shared_deps, file_path) + step.output = code + write_file(os.path.join(Agent.get_workspace(task.task_id), file_path), code) path = Path("./" + file_path) - artifact = Artifact( - artifact_id=str(uuid.uuid4()), - file_name=path.name, + await Agent.db.create_artifact( + task_id=task.task_id, + step_id=step.step_id, relative_path=str(path.parent), + file_name=path.name, ) - step.output = code - step.artifacts.append(artifact) return step diff --git a/agent/python/poetry.lock b/agent/python/poetry.lock index 12095c9..76d7e95 100644 --- a/agent/python/poetry.lock +++ b/agent/python/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +[[package]] +name = "aiofiles" +version = "23.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "aiofiles-23.1.0-py3-none-any.whl", hash = "sha256:9312414ae06472eb6f1d163f555e466a23aed1c8f60c30cccf7121dba2e53eb2"}, + {file = "aiofiles-23.1.0.tar.gz", hash = "sha256:edd247df9a19e0db16534d4baaf536d6609a43e1de5401d7a4c1c148753a1635"}, +] + [[package]] name = "anyio" version = "3.7.1" @@ -1102,6 +1113,20 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-multipart" +version = "0.0.6" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "python_multipart-0.0.6-py3-none-any.whl", hash = "sha256:ee698bab5ef148b0a760751c261902cd096e57e10558e11aca17646b74ee1c18"}, + {file = "python_multipart-0.0.6.tar.gz", hash = "sha256:e9925a80bb668529f1b67c7fdb0a5dacdd7cbfc6fb0bff3ea443fe22bdd62132"}, +] + +[package.extras] +dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==1.7.3)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] + [[package]] name = "pyyaml" version = "6.0.1" @@ -1482,4 +1507,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7, <4.0.0" -content-hash = "9c40d062053ac8682c997ddd58f82bcf31973356199e711c228a279cc820bd75" +content-hash = "4119a6250adc5d266e44ec3738002f73581d47b808fce7b82ff0ba53ecaac0c7" diff --git a/agent/python/pyproject.toml b/agent/python/pyproject.toml index 2e6e8af..fb60021 100644 --- a/agent/python/pyproject.toml +++ b/agent/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "agent-protocol" -version = "0.2.2" +version = "0.2.3" description = "API for interacting with Agent" authors = ["e2b "] license = "MIT" @@ -17,6 +17,8 @@ pytest = "^7.0.0" pydantic = "^1.10.5, <2" click = "^8.1.6" requests = "^2.31.0" +python-multipart = "^0.0.6" +aiofiles = "^23.1.0" [tool.poetry.group.dev.dependencies] fastapi-code-generator = "^0.4.2" diff --git a/agent_client/python/agent_protocol_client/api/agent_api.py b/agent_client/python/agent_protocol_client/api/agent_api.py index b6223ab..0c0b7c8 100644 --- a/agent_client/python/agent_protocol_client/api/agent_api.py +++ b/agent_client/python/agent_protocol_client/api/agent_api.py @@ -20,7 +20,7 @@ from typing_extensions import Annotated from typing import overload, Optional, Union, Awaitable -from pydantic import Field, StrictStr +from pydantic import Field, StrictBytes, StrictStr from typing import List, Optional, Union @@ -1422,3 +1422,230 @@ def list_agent_tasks_ids_with_http_info( collection_formats=_collection_formats, _request_auth=_params.get("_request_auth"), ) + + @overload + async def upload_agent_task_artifacts( + self, + task_id: Annotated[StrictStr, Field(..., description="ID of the task")], + file: Annotated[ + Union[StrictBytes, StrictStr], Field(..., description="File to upload.") + ], + relative_path: Annotated[ + Optional[StrictStr], + Field( + description="Relative path of the artifact in the agent's workspace." + ), + ] = None, + **kwargs + ) -> Artifact: # noqa: E501 + ... + + @overload + def upload_agent_task_artifacts( + self, + task_id: Annotated[StrictStr, Field(..., description="ID of the task")], + file: Annotated[ + Union[StrictBytes, StrictStr], Field(..., description="File to upload.") + ], + relative_path: Annotated[ + Optional[StrictStr], + Field( + description="Relative path of the artifact in the agent's workspace." + ), + ] = None, + async_req: Optional[bool] = True, + **kwargs + ) -> Artifact: # noqa: E501 + ... + + @validate_arguments + def upload_agent_task_artifacts( + self, + task_id: Annotated[StrictStr, Field(..., description="ID of the task")], + file: Annotated[ + Union[StrictBytes, StrictStr], Field(..., description="File to upload.") + ], + relative_path: Annotated[ + Optional[StrictStr], + Field( + description="Relative path of the artifact in the agent's workspace." + ), + ] = None, + async_req: Optional[bool] = None, + **kwargs + ) -> Union[Artifact, Awaitable[Artifact]]: # noqa: E501 + """Upload an artifact for the specified task. # noqa: E501 + + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.upload_agent_task_artifacts(task_id, file, relative_path, async_req=True) + >>> result = thread.get() + + :param task_id: ID of the task (required) + :type task_id: str + :param file: File to upload. (required) + :type file: bytearray + :param relative_path: Relative path of the artifact in the agent's workspace. + :type relative_path: str + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: Artifact + """ + kwargs["_return_http_data_only"] = True + if "_preload_content" in kwargs: + raise ValueError( + "Error! Please call the upload_agent_task_artifacts_with_http_info method with `_preload_content` instead and obtain raw data from ApiResponse.raw_data" + ) + if async_req is not None: + kwargs["async_req"] = async_req + return self.upload_agent_task_artifacts_with_http_info( + task_id, file, relative_path, **kwargs + ) # noqa: E501 + + @validate_arguments + def upload_agent_task_artifacts_with_http_info( + self, + task_id: Annotated[StrictStr, Field(..., description="ID of the task")], + file: Annotated[ + Union[StrictBytes, StrictStr], Field(..., description="File to upload.") + ], + relative_path: Annotated[ + Optional[StrictStr], + Field( + description="Relative path of the artifact in the agent's workspace." + ), + ] = None, + **kwargs + ) -> ApiResponse: # noqa: E501 + """Upload an artifact for the specified task. # noqa: E501 + + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.upload_agent_task_artifacts_with_http_info(task_id, file, relative_path, async_req=True) + >>> result = thread.get() + + :param task_id: ID of the task (required) + :type task_id: str + :param file: File to upload. (required) + :type file: bytearray + :param relative_path: Relative path of the artifact in the agent's workspace. + :type relative_path: str + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _preload_content: if False, the ApiResponse.data will + be set to none and raw_data will store the + HTTP response body without reading/decoding. + Default is True. + :type _preload_content: bool, optional + :param _return_http_data_only: response data instead of ApiResponse + object with status code, headers, etc + :type _return_http_data_only: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the authentication + in the spec for a single request. + :type _request_auth: dict, optional + :type _content_type: string, optional: force content-type for the request + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: tuple(Artifact, status_code(int), headers(HTTPHeaderDict)) + """ + + _params = locals() + + _all_params = ["task_id", "file", "relative_path"] + _all_params.extend( + [ + "async_req", + "_return_http_data_only", + "_preload_content", + "_request_timeout", + "_request_auth", + "_content_type", + "_headers", + ] + ) + + # validate the arguments + for _key, _val in _params["kwargs"].items(): + if _key not in _all_params: + raise ApiTypeError( + "Got an unexpected keyword argument '%s'" + " to method upload_agent_task_artifacts" % _key + ) + _params[_key] = _val + del _params["kwargs"] + + _collection_formats = {} + + # process the path parameters + _path_params = {} + if _params["task_id"]: + _path_params["task_id"] = _params["task_id"] + + # process the query parameters + _query_params = [] + # process the header parameters + _header_params = dict(_params.get("_headers", {})) + # process the form parameters + _form_params = [] + _files = {} + if _params["file"]: + _files["file"] = _params["file"] + + if _params["relative_path"]: + _form_params.append(("relative_path", _params["relative_path"])) + + # process the body parameter + _body_params = None + # set the HTTP header `Accept` + _header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) # noqa: E501 + + # set the HTTP header `Content-Type` + _content_types_list = _params.get( + "_content_type", + self.api_client.select_header_content_type(["multipart/form-data"]), + ) + if _content_types_list: + _header_params["Content-Type"] = _content_types_list + + # authentication setting + _auth_settings = [] # noqa: E501 + + _response_types_map = { + "200": "Artifact", + } + + return self.api_client.call_api( + "/agent/tasks/{task_id}/artifacts", + "POST", + _path_params, + _query_params, + _header_params, + body=_body_params, + post_params=_form_params, + files=_files, + response_types_map=_response_types_map, + auth_settings=_auth_settings, + async_req=_params.get("async_req"), + _return_http_data_only=_params.get("_return_http_data_only"), # noqa: E501 + _preload_content=_params.get("_preload_content", True), + _request_timeout=_params.get("_request_timeout"), + collection_formats=_collection_formats, + _request_auth=_params.get("_request_auth"), + ) diff --git a/agent_client/python/agent_protocol_client/docs/AgentApi.md b/agent_client/python/agent_protocol_client/docs/AgentApi.md index 0c8d6c7..70abc4b 100644 --- a/agent_client/python/agent_protocol_client/docs/AgentApi.md +++ b/agent_client/python/agent_protocol_client/docs/AgentApi.md @@ -12,6 +12,7 @@ All URIs are relative to _http://localhost_ | [**list_agent_task_artifacts**](AgentApi.md#list_agent_task_artifacts) | **GET** /agent/tasks/{task_id}/artifacts | List all artifacts that have been created for the given task. | | [**list_agent_task_steps**](AgentApi.md#list_agent_task_steps) | **GET** /agent/tasks/{task_id}/steps | List all steps for the specified task. | | [**list_agent_tasks_ids**](AgentApi.md#list_agent_tasks_ids) | **GET** /agent/tasks | List all tasks that have been created for the agent. | +| [**upload_agent_task_artifacts**](AgentApi.md#upload_agent_task_artifacts) | **POST** /agent/tasks/{task_id}/artifacts | Upload an artifact for the specified task. | # **create_agent_task** @@ -542,3 +543,73 @@ No authorization required | **0** | Internal Server Error | - | [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **upload_agent_task_artifacts** + +> Artifact upload_agent_task_artifacts(task_id, file, relative_path=relative_path) + +Upload an artifact for the specified task. + +### Example + +```python +import time +import os +import agent_protocol_client +from agent_protocol_client.models.artifact import Artifact +from agent_protocol_client.rest import ApiException +from pprint import pprint + +# Defining the host is optional and defaults to http://localhost +# See configuration.py for a list of all supported configuration parameters. +configuration = agent_protocol_client.Configuration( + host = "http://localhost" +) + + +# Enter a context with an instance of the API client +async with agent_protocol_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = agent_protocol_client.AgentApi(api_client) + task_id = 'task_id_example' # str | ID of the task + file = None # bytearray | File to upload. + relative_path = 'relative_path_example' # str | Relative path of the artifact in the agent's workspace. (optional) + + try: + # Upload an artifact for the specified task. + api_response = await api_instance.upload_agent_task_artifacts(task_id, file, relative_path=relative_path) + print("The response of AgentApi->upload_agent_task_artifacts:\n") + pprint(api_response) + except Exception as e: + print("Exception when calling AgentApi->upload_agent_task_artifacts: %s\n" % e) +``` + +### Parameters + +| Name | Type | Description | Notes | +| ----------------- | ------------- | ----------------------------------------------------------- | ---------- | +| **task_id** | **str** | ID of the task | +| **file** | **bytearray** | File to upload. | +| **relative_path** | **str** | Relative path of the artifact in the agent's workspace. | [optional] | + +### Return type + +[**Artifact**](Artifact.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: multipart/form-data +- **Accept**: application/json + +### HTTP response details + +| Status code | Description | Response headers | +| ----------- | ------------------------------------- | ---------------- | +| **200** | Returned the content of the artifact. | - | +| **0** | Internal Server Error | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/agent_client/python/pyproject.toml b/agent_client/python/pyproject.toml index ed13391..95d5b66 100644 --- a/agent_client/python/pyproject.toml +++ b/agent_client/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "agent-protocol-client" -version = "0.2.0" +version = "0.2.1" description = "Agent Communication Protocol Client" authors = ["e2b "] license = "MIT" diff --git a/openapi.yml b/openapi.yml index b0e5bad..d509db6 100644 --- a/openapi.yml +++ b/openapi.yml @@ -168,6 +168,31 @@ paths: $ref: '#/components/schemas/Artifact' default: description: Internal Server Error + post: + summary: Upload an artifact for the specified task. + tags: [agent] + operationId: uploadAgentTaskArtifacts + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/ArtifactUpload' + parameters: + - name: task_id + in: path + description: ID of the task + required: true + schema: + type: string + responses: + 200: + description: Returned the content of the artifact. + content: + application/json: + schema: + $ref: '#/components/schemas/Artifact' + default: + description: Internal Server Error /agent/tasks/{task_id}/artifacts/{artifact_id}: get: @@ -220,6 +245,20 @@ components: description: Relative path of the artifact in the agent's workspace. type: string + ArtifactUpload: + type: object + required: + - 'file' + description: Artifact to upload to the agent. + properties: + file: + description: File to upload. + type: string + format: binary + relative_path: + description: Relative path of the artifact in the agent's workspace. + type: string + StepInput: description: Input parameters for the task step. Any value is allowed.