Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,166 @@ ms cache clear --repo-id my-org/old-model --repo-type model --yes

</details>

### `ms agent`

Manage agent workspace files across local directories and remote repositories. Supports multiple frameworks with cross-framework conversion.

```bash
ms agent upload -f qwenpaw -r user/my-agent # upload local files
ms agent download -f qwenpaw -r user/my-agent # download to local
ms agent watch -f qwenpaw -r user/my-agent --pull # bidirectional sync
ms agent convert --from-framework nanobot --target-framework qwenpaw # convert format
ms agent status -f qwenpaw # show local status
ms agent list --owner user # list remote repos
ms agent backups -f qwenpaw # list backups
ms agent restore --from-backup last -f qwenpaw # restore from backup
ms agent stop # stop watch daemon
```

> **Supported frameworks:** nanobot, openclaw, hermes, qwenpaw, openhuman, qoder

<details>
<summary>Subcommands</summary>

#### `ms agent upload`

Pack and upload local agent workspace files to a remote repository.

```bash
ms agent upload -f qwenpaw -r user/my-agent
ms agent upload -f nanobot -r user/my-agent -n my-agent --dry-run
```

| Option | Required | Description |
|--------|----------|-------------|
| `-f, --framework FW` | yes | Agent framework |
| `-r, --repo REPO` | yes | Remote repo identifier (`owner/name`) |
| `-n, --name NAME` | no | Local agent name; auto-selects if only one exists |
| `--local-dir DIR` | no | Override local workspace root |
| `--dry-run` | no | List files that would be uploaded without uploading |

#### `ms agent download`

Download remote agent files and write to local workspace.

```bash
ms agent download -f qwenpaw -r user/my-agent
ms agent download -f nanobot -r user/my-agent --target-framework qwenpaw
```

| Option | Required | Description |
|--------|----------|-------------|
| `-f, --framework FW` | yes | Agent framework |
| `-r, --repo REPO` | yes | Remote repo identifier (`owner/name`) |
| `-n, --name NAME` | no | Local agent name to write as (default: `default`) |
| `--local-dir DIR` | no | Override local workspace root |
| `--target-framework FW` | no | Convert to a different framework on download |
| `--dry-run` | no | List files that would be written without writing |

#### `ms agent watch`

Launch a background daemon that watches local changes and pushes to remote. With `--pull`, also pulls remote changes (bidirectional sync).

```bash
ms agent watch -f qwenpaw -r user/my-agent # push-only (default)
ms agent watch -f qwenpaw -r user/my-agent --pull # bidirectional
```

| Option | Required | Description |
|--------|----------|-------------|
| `-f, --framework FW` | yes | Agent framework |
| `-r, --repo REPO` | yes | Remote repo identifier (`owner/name`) |
| `-n, --name NAME` | no | Agent name to sync (default: ALL agents in workspace) |
| `--local-dir DIR` | no | Override local workspace root |
| `--pull` | no | Enable bidirectional sync (default: push-only) |

#### `ms agent list`

Query and display remote agent repositories with pagination.

```bash
ms agent list --owner user --page-size 20
```

| Option | Required | Description |
|--------|----------|-------------|
| `--owner OWNER` | no | Filter by owner username or organization |
| `--page N` | no | Page number (default: `1`) |
| `--page-size N` | no | Items per page (default: `10`) |

#### `ms agent status`

Display discovered agents, file counts, and file paths for a framework.

```bash
ms agent status -f qwenpaw
```

| Option | Required | Description |
|--------|----------|-------------|
| `-f, --framework FW` | yes | Agent framework |
| `--local-dir DIR` | no | Override local workspace root |

#### `ms agent backups`

List backup zip files. Backups are named `{framework}_{name}_{date}_{time}.zip`.

```bash
ms agent backups -f qwenpaw
ms agent backups -f qwenpaw -n my-agent
```

| Option | Required | Description |
|--------|----------|-------------|
| `-f, --framework FW` | no | Filter backups by framework name prefix |
| `-n, --name NAME` | no | Filter backups by agent name |
| `--local-dir DIR` | no | Override local workspace root |

#### `ms agent restore`

Restore workspace from a backup zip. Backs up current state before overwriting.

```bash
ms agent restore --from-backup last -f qwenpaw
ms agent restore --from-backup qwenpaw_default_20260701_120000.zip
```

| Option | Required | Description |
|--------|----------|-------------|
| `--from-backup TARGET` | yes | `last` (most recent matching) or a specific filename |
| `-f, --framework FW` | no | Filter backup candidates by framework (with `last`) |
| `-n, --name NAME` | no | Filter backup candidates by agent name (with `last`) |
| `--local-dir DIR` | no | Override restore target directory |

#### `ms agent convert`

Convert agent workspace files from one framework format to another (local only, no network). Skips default template files with no custom content; backs up existing target files before writing.

```bash
ms agent convert --from-framework nanobot --target-framework qwenpaw
ms agent convert --from-framework hermes --target-framework qwenpaw --from-name my-agent --dry-run
```

| Option | Required | Description |
|--------|----------|-------------|
| `--from-framework FW` | yes | Source framework to read from |
| `--target-framework FW` | yes | Target framework to write to |
| `--from-name NAME` | no | Source agent name (default: `default`) |
| `--target-name NAME` | no | Target agent name (default: same as `--from-name`) |
| `--local-dir DIR` | no | Source workspace root (default: source framework path) |
| `--out-dir DIR` | no | Destination directory (default: target framework path) |
| `--dry-run` | no | Show what would be written without writing |

#### `ms agent stop`

Gracefully stop the background watch daemon (cross-platform: stop-file + SIGTERM).

```bash
ms agent stop
```

</details>

---

## SDK API Overview
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ build-backend = "setuptools.build_meta"
where = ["src"]

[tool.setuptools.package-data]
modelscope_hub = ["py.typed"]
modelscope_hub = ["py.typed", "agent/defaults/**/*.md"]

[tool.setuptools.dynamic]
version = {attr = "modelscope_hub.version.__version__"}
Expand Down
79 changes: 67 additions & 12 deletions src/modelscope_hub/_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,41 @@ def __exit__(self, *_exc: object) -> None:
self.close()

# ------------------------------------------------------------------
# Request plumbing
# Public request interface
# ------------------------------------------------------------------
def request(
self,
method: str,
path: str = "",
*,
url: str | None = None,
params: Mapping[str, Any] | QueryParams | None = None,
json_body: Any | None = None,
data: Any | None = None,
files: Any | None = None,
headers: Mapping[str, str] | None = None,
require_token: bool = True,
unwrap: bool = True,
timeout: float | None = None,
) -> Any:
"""Execute an HTTP request.

When *url* is provided the request targets that absolute URL directly
(useful for endpoints outside ``/openapi/v1``, signed OSS URLs, etc.).
Otherwise the request is routed through ``self._url(path)``.

When *unwrap* is ``False`` the raw :class:`requests.Response` is returned.
"""
return self._request(
method, path,
url=url,
params=params, json_body=json_body, data=data, files=files,
headers=headers, require_token=require_token, unwrap=unwrap,
timeout=timeout,
)

# ------------------------------------------------------------------
# Request plumbing (internal)
# ------------------------------------------------------------------
@property
def base_url(self) -> str:
Expand All @@ -124,12 +158,17 @@ def _url(self, path: str) -> str:
# discard the ``/openapi/v1`` prefix. Normalise to a relative form.
return urljoin(self.base_url, path.lstrip("/"))

def _auth_headers(self, *, require_token: bool = False) -> dict[str, str]:
def _resolve_token(self) -> str | None:
"""Resolve the current API token (from config or persisted credential)."""
token = self._config.token
if not token:
token = self._config.load_token()
if token:
self._config.token = token
return token

def _auth_headers(self, *, require_token: bool = False) -> dict[str, str]:
token = self._resolve_token()
if not token:
if require_token:
raise AuthenticationError(
Expand All @@ -138,6 +177,13 @@ def _auth_headers(self, *, require_token: bool = False) -> dict[str, str]:
return {}
return {"Authorization": f"Bearer {token}"}

def _auth_cookies(self) -> dict[str, str]:
"""Build session cookies for /api/v1/ endpoints that use cookie auth."""
token = self._resolve_token()
if not token:
return {}
return {"m_session_id": token, "modelscope_session": token}

@staticmethod
def _flatten_filters(filters: Filters) -> QueryParams:
"""Serialise a flat mapping into ``filter.key=value`` tuples."""
Expand Down Expand Up @@ -171,8 +217,9 @@ def _merge_params(
def _request(
self,
method: str,
path: str,
path: str = "",
*,
url: str | None = None,
params: Mapping[str, Any] | QueryParams | None = None,
json_body: Any | None = None,
data: Any | None = None,
Expand All @@ -186,8 +233,11 @@ def _request(

The method centralises authentication, retries on transient errors,
and decoding of the standard ``{"success": ..., "data": ...}`` envelope.

When *url* is given, it is used as-is (absolute URL). Otherwise the
final URL is derived from *path* via :meth:`_url`.
"""
url = self._url(path)
final_url = url or self._url(path)
merged_headers = dict(self._auth_headers(require_token=require_token))
if headers:
merged_headers.update(headers)
Expand All @@ -197,16 +247,17 @@ def _request(
last_exc: BaseException | None = None

for attempt in range(1, attempts + 1):
_logger.debug("%s %s", method_upper, url)
_logger.debug("%s %s", method_upper, final_url)
try:
response = self._session.request(
method=method_upper,
url=url,
url=final_url,
params=params,
json=json_body,
data=data,
files=files,
headers=merged_headers,
cookies=self._auth_cookies(),
timeout=timeout if timeout is not None else self._timeout,
)
except requests.Timeout as exc:
Expand All @@ -216,12 +267,12 @@ def _request(
except requests.RequestException as exc: # pragma: no cover - defensive
last_exc = NetworkError(f"Request failed: {exc}")
else:
_logger.debug("%s %s -> %s", method_upper, url, response.status_code)
_logger.debug("%s %s -> %s", method_upper, final_url, response.status_code)
if response.status_code >= 400:
_logger.debug(
"Request failed: %s %s params=%s status=%s body=%s",
method_upper,
url,
final_url,
params,
response.status_code,
response.text[:500] if response.text else "",
Expand All @@ -231,19 +282,21 @@ def _request(
except _RETRYABLE_EXC as exc: # type: ignore[misc]
last_exc = exc
else:
return self._decode(response, unwrap=unwrap)
if not unwrap:
return response
return self._decode(response, unwrap=True)

# Retry policy: idempotent methods + known-idempotent POST paths.
is_retryable = (
method_upper in _IDEMPOTENT_METHODS
or (method_upper == "POST" and any(path.endswith(p) for p in _RETRYABLE_POST_PATHS))
or (method_upper == "POST" and path and any(path.endswith(p) for p in _RETRYABLE_POST_PATHS))
)
if attempt >= attempts or not is_retryable:
break
backoff = min(2 ** (attempt - 1), 16)
_logger.debug(
"Retrying %s %s after %s (attempt %d/%d)",
method_upper, path, last_exc, attempt, attempts,
method_upper, final_url, last_exc, attempt, attempts,
)
time.sleep(backoff)

Expand All @@ -261,9 +314,11 @@ def _decode(response: requests.Response, *, unwrap: bool) -> Any:
return response.content if not unwrap else response.text
if not unwrap or not isinstance(payload, dict):
return payload
# The OpenAPI envelope always carries a ``data`` field on success.
# The OpenAPI envelope carries a ``data`` (or ``Data``) field on success.
if "data" in payload:
return payload["data"]
if "Data" in payload:
return payload["Data"]
return payload

# ==================================================================
Expand Down
Loading