feat: add guided exploration and share user-started MCP backend

This commit is contained in:
chenmingxuan committed 2026-10-16 18:32:46 +08:00
1 parent f86db1ced6
commit ee256ad7ce
144 files changed
+8625 -2075

No files matched your search

+22
View File
@@ -44,6 +44,28 @@ and bearer-token authentication and exposes only task-related APIs.
## Usage
For interactive case design, use `client.exploration` with the same actions as the
web workspace and `mobile_exploration`. Use a stable request ID when retrying a write:
```python
session = await client.exploration(
"create",
request_id="explore-settings-create",
payload={"goal": "Explore the Settings search entry; ask when expectations are unclear"},
)
await client.exploration(
"start",
exploration_id=session["id"],
request_id="explore-settings-start",
)
state = await client.exploration("get", exploration_id=session["id"])
```
The default exploration model is `gpt-6.1-sol / high`. Timeouts preserve progress
and pause; `completed` does not imply a passing test. Read `runs[].test_result`.
See [exploration modes and actions](../../docs/exploration-mode.md) for manual
demonstrations, versioned cases and reruns.
```python
import asyncio
import os
@@ -14,6 +14,7 @@ import asyncio
import os
import time
import uuid
import re
from collections.abc import Mapping
from typing import Any, Literal, Protocol
@@ -371,6 +372,75 @@ class ArtemisClient:
json_body=json_body,
)
async def exploration(
self,
action: str,
*,
exploration_id: str | None = None,
request_id: str | None = None,
payload: Mapping[str, Any] | None = None,
demonstration_id: str | None = None,
after: int = 0,
version: int | None = None,
) -> Any:
"""Use the shared guided-exploration API; queries never start device work.
Actions: list/get/case, create/start/message/pause/continue/stop,
demonstrate/demo_finish/demo_extend/demo_cancel, save_case/generate/validate/batch.
Mutations require a stable request_id except pause/stop. Generate returns a
synthesis job; poll get.generations. Batch creates a draft only.
"""
def identifier(value):
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
raise ValueError("Invalid exploration/demonstration identifier")
return value
base = "/api/explorations"
if action in {"list", "get", "case"}:
if payload or request_id:
raise ValueError("Read operations cannot contain mutation fields")
if action == "list":
return await self._request("GET", base)
path = f"{base}/{identifier(exploration_id)}"
if action == "get":
if after < 0:
raise ValueError("after must be nonnegative")
return await self._request("GET", f"{path}?after={int(after)}")
if version is not None and version < 1:
raise ValueError("version must be positive")
return await self._request(
"GET", path + "/case" + (f"?version={int(version)}" if version is not None else "")
)
if action not in {"pause", "stop"} and not (request_id and request_id.strip()):
raise ValueError("Mutation requires a stable request_id")
body = dict(payload or {})
if "request_id" in body:
raise ValueError("Use the separate request_id argument")
if request_id:
body["request_id"] = request_id
if action == "create":
return await self._request("POST", base, json_body=body)
suffix = {
"start": "start",
"message": "messages",
"pause": "pause",
"continue": "continue",
"stop": "stop",
"demonstrate": "demonstrations",
"save_case": "case",
"generate": "generate",
"validate": "validate",
"batch": "batch",
}.get(action)
if action in {"demo_finish", "demo_extend", "demo_cancel"}:
suffix = f"demonstrations/{identifier(demonstration_id)}/{action.removeprefix('demo_')}"
if suffix is None:
raise ValueError("Unsupported exploration action")
return await self._request(
"POST", f"{base}/{identifier(exploration_id)}/{suffix}", json_body=body
)
@staticmethod
def _mapping(payload: Any, *, endpoint: str) -> Mapping[str, Any]:
if not isinstance(payload, Mapping):
@@ -56,6 +56,25 @@ class ArtemisClientTests(unittest.IsolatedAsyncioTestCase):
transport=self.transport,
)
async def test_exploration_queries_and_mutations_use_shared_api(self) -> None:
self.transport.add("GET", "/api/explorations/session?after=2", {"id": "session"})
self.transport.add(
"POST", "/api/explorations/session/demonstrations/demo/finish", {"status": "analyzing"}
)
await self.client.exploration("get", exploration_id="session", after=2)
await self.client.exploration(
"demo_finish",
exploration_id="session",
demonstration_id="demo",
request_id="finish-once",
)
self.assertEqual(self.transport.calls[-1][2], {"request_id": "finish-once"})
self.assertEqual(self.transport.calls[0][0], "GET")
with self.assertRaises(ValueError):
await self.client.exploration("start", exploration_id="session")
with self.assertRaises(ValueError):
await self.client.exploration("get", exploration_id="../private")
async def test_submit_sends_idempotent_session_id(self) -> None:
task_id = "00000000-0000-4000-8000-000000000123"
self.transport.add(