Compare commits

..

2 commits

7 changed files with 206 additions and 6 deletions

View file

@ -11,6 +11,7 @@ REST API-обертка над `browser-use` RPC (`POST /run` в контейн
- `POST /api/browser/tasks` возвращает `202` и `task_id`
- `GET /api/browser/tasks/{task_id}` возвращает `queued/running/...`
- `GET /api/browser/tasks/{task_id}/result` возвращает `202`, пока задача не завершена
- `GET /api/browser/tasks/{task_id}/history` возвращает историю шагов агента
## Архитектура
@ -102,6 +103,34 @@ Response `202`:
- `202` если задача еще `queued/running`
- `200` с финальным payload после завершения
### `GET /api/browser/tasks/{task_id}/history`
- `202` если задача еще `queued/running`
- `200` с финальной историей шагов после завершения
Пример ответа `200`:
```json
{
"task_id": "53f54fa4c1f24219b3949d56b0457875",
"status": "succeeded",
"history": [
{
"step": 1,
"kind": "thought",
"content": "Open target page",
"data": {"value": "Open target page"}
},
{
"step": 2,
"kind": "action",
"content": "Click login",
"data": {"value": "Click login"}
}
]
}
```
## Быстрый end-to-end пример
```zsh
@ -117,4 +146,5 @@ TASK_ID=$(python -c "import json,sys;print(json.loads(sys.argv[1])['task_id'])"
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID"
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID/result"
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID/history"
```

View file

@ -41,3 +41,21 @@ class BrowserTaskResultResponse(BaseModel):
result: str | None = Field(default=None, description="Итоговый текстовый результат")
error: str | None = Field(default=None, description="Текст ошибки, если выполнение не удалось")
raw_response: dict[str, Any] | None = Field(default=None, description="Сырой ответ от browser-use RPC")
class TaskHistoryEvent(BaseModel):
"""Одно действие/шаг в истории выполнения browser-use агента."""
step: int = Field(..., description="Порядковый номер события в истории")
kind: str = Field(..., description="Тип события (thought/action/error/system)")
content: str | None = Field(default=None, description="Краткое текстовое описание события")
data: dict[str, Any] = Field(default_factory=dict, description="Дополнительные структурированные данные")
class BrowserTaskHistoryResponse(BaseModel):
"""История действий агента для конкретной задачи."""
task_id: str
status: TaskStatus
history: list[TaskHistoryEvent] = Field(default_factory=list)

View file

@ -20,6 +20,7 @@ class TaskRecord:
result: str | None = None
error: str | None = None
raw_response: dict[str, Any] | None = None
history: list[dict[str, Any]] = field(default_factory=list)
@property
def execution_time(self) -> float:
@ -61,6 +62,7 @@ class TaskStore:
raw_response: dict[str, Any] | None,
error: str | None,
result: str | None = None,
history: list[dict[str, Any]] | None = None,
) -> TaskRecord | None:
async with self._lock:
rec = self._tasks.get(task_id)
@ -72,5 +74,6 @@ class TaskStore:
raw_response.get("error") if isinstance(raw_response, dict) else None)
rec.result = result if result is not None else (
raw_response.get("result") if isinstance(raw_response, dict) else None)
rec.history = list(history or [])
rec.status = TaskStatus.succeeded if success else TaskStatus.failed
return rec

View file

@ -3,9 +3,11 @@ from fastapi.responses import JSONResponse
from api.contracts.task_schemas import (
BrowserTaskAcceptedResponse,
BrowserTaskHistoryResponse,
BrowserTaskRequest,
BrowserTaskResultResponse,
BrowserTaskStatusResponse,
TaskHistoryEvent,
)
from api.domain.task_status import TaskStatus
from api.repositories.task_store import TaskRecord
@ -69,6 +71,32 @@ async def get_task_result(
)
@router.get("/tasks/{task_id}/history", response_model=BrowserTaskHistoryResponse)
async def get_task_history(
task_id: str,
service: TaskService = Depends(get_task_service),
) -> JSONResponse | BrowserTaskHistoryResponse:
rec = await service.get_task(task_id)
if rec is None:
raise HTTPException(status_code=404, detail="Task not found")
if rec.status in (TaskStatus.queued, TaskStatus.running):
return JSONResponse(
status_code=202,
content={
"task_id": rec.task_id,
"status": rec.status.value,
"history": rec.history,
},
)
return BrowserTaskHistoryResponse(
task_id=rec.task_id,
status=rec.status,
history=_to_history_events(rec),
)
def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
return BrowserTaskStatusResponse(
task_id=rec.task_id,
@ -78,3 +106,24 @@ def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
finished_at=rec.finished_at,
error=rec.error,
)
def _to_history_events(rec: TaskRecord) -> list[TaskHistoryEvent]:
events: list[TaskHistoryEvent] = []
for index, item in enumerate(rec.history, start=1):
kind = str(item.get("kind") or item.get("type") or "system")
content = item.get("content")
if content is not None:
content = str(content)
data = item.get("data")
if not isinstance(data, dict):
data = {}
step = item.get("step")
if not isinstance(step, int):
step = index
events.append(TaskHistoryEvent(step=step, kind=kind, content=content, data=data))
return events

View file

@ -59,6 +59,7 @@ class TaskService:
raw_response=raw,
error=None,
result=raw.get("result") if isinstance(raw, dict) else None,
history=self._extract_history(raw),
)
except asyncio.TimeoutError:
await self._store.set_done(
@ -66,6 +67,7 @@ class TaskService:
success=False,
raw_response=None,
error="Timeout exceeded",
history=None,
)
except BrowserRpcError as exc:
await self._store.set_done(
@ -73,6 +75,7 @@ class TaskService:
success=False,
raw_response=None,
error=str(exc),
history=None,
)
except Exception as exc:
await self._store.set_done(
@ -80,4 +83,21 @@ class TaskService:
success=False,
raw_response=None,
error=f"Internal error: {exc}",
history=None,
)
@staticmethod
def _extract_history(raw: dict | None) -> list[dict]:
if not isinstance(raw, dict):
return []
events = raw.get("history")
if not isinstance(events, list):
return []
normalized: list[dict] = []
for event in events:
if isinstance(event, dict):
normalized.append(event)
return normalized

View file

@ -1,9 +1,3 @@
---
## Файл: assets/config.example.json
```json
{
"browser": {
"headless": true,

View file

@ -2,6 +2,7 @@ import asyncio
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib import error, request
from browser_use import Agent, Browser, ChatOpenAI
@ -36,6 +37,7 @@ async def run_browser_task(task):
return {
"success": True,
"result": history.final_result(),
"history": _extract_history_events(history),
"browser_view": browser_view_url,
}
except Exception as err:
@ -47,6 +49,90 @@ async def run_browser_task(task):
pass
def _to_jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _to_jsonable(val) for key, val in value.items()}
if isinstance(value, (list, tuple, set)):
return [_to_jsonable(item) for item in value]
for method_name in ("model_dump", "dict", "to_dict"):
method = getattr(value, method_name, None)
if callable(method):
try:
dumped = method()
return _to_jsonable(dumped)
except Exception:
pass
return str(value)
def _call_history_items(history: Any, attr_name: str) -> list[Any]:
method = getattr(history, attr_name, None)
if not callable(method):
return []
try:
raw: Any = method()
except Exception:
return []
if raw is None:
return []
if isinstance(raw, list):
return raw
if isinstance(raw, (str, bytes, dict)):
return [raw]
try:
return list(raw)
except TypeError:
return [raw]
except Exception:
return [raw]
def _extract_history_events(history: Any) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
def append_many(kind: str, items: list[Any]) -> None:
if not items:
return
for item in items:
normalized = _to_jsonable(item)
payload = normalized if isinstance(normalized, dict) else {"value": normalized}
content = normalized if isinstance(normalized, str) else json.dumps(normalized, ensure_ascii=False)
events.append(
{
"step": len(events) + 1,
"kind": kind,
"content": content,
"data": payload,
}
)
append_many("thought", _call_history_items(history, "model_thoughts"))
append_many("action", _call_history_items(history, "model_actions"))
append_many("error", _call_history_items(history, "errors"))
if events:
return events
fallback = _to_jsonable(history)
return [
{
"step": 1,
"kind": "system",
"content": fallback if isinstance(fallback, str) else json.dumps(fallback, ensure_ascii=False),
"data": fallback if isinstance(fallback, dict) else {"value": fallback},
}
]
class BrowserUseRPCHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/health":