From fb7ab50de6525c3f57a8b28b8d76782005d854e5 Mon Sep 17 00:00:00 2001 From: fedorkobylkevitch Date: Wed, 22 Apr 2026 15:08:27 +0300 Subject: [PATCH] add history endpoint --- api/README.md | 30 +++++++++++ api/contracts/task_schemas.py | 18 +++++++ api/repositories/task_store.py | 3 ++ api/routes/tasks.py | 49 ++++++++++++++++++ api/services/task_service.py | 20 +++++++ assets/config.example.json | 6 --- browser_env/browser_use_runner.py | 86 +++++++++++++++++++++++++++++++ 7 files changed, 206 insertions(+), 6 deletions(-) diff --git a/api/README.md b/api/README.md index 74dbed10..bfe4f16e 100644 --- a/api/README.md +++ b/api/README.md @@ -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" ``` diff --git a/api/contracts/task_schemas.py b/api/contracts/task_schemas.py index bcad3cbe..9cbc6865 100644 --- a/api/contracts/task_schemas.py +++ b/api/contracts/task_schemas.py @@ -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) + diff --git a/api/repositories/task_store.py b/api/repositories/task_store.py index bc66cd18..3467a176 100644 --- a/api/repositories/task_store.py +++ b/api/repositories/task_store.py @@ -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 diff --git a/api/routes/tasks.py b/api/routes/tasks.py index 94ed6238..90a3a989 100644 --- a/api/routes/tasks.py +++ b/api/routes/tasks.py @@ -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 + diff --git a/api/services/task_service.py b/api/services/task_service.py index 97fc6b39..f331ff1b 100644 --- a/api/services/task_service.py +++ b/api/services/task_service.py @@ -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 + diff --git a/assets/config.example.json b/assets/config.example.json index ae331184..a76d59f8 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -1,9 +1,3 @@ - ---- - -## ⚙️ Файл: assets/config.example.json - -```json { "browser": { "headless": true, diff --git a/browser_env/browser_use_runner.py b/browser_env/browser_use_runner.py index 08ed6b42..f54a9ce6 100644 --- a/browser_env/browser_use_runner.py +++ b/browser_env/browser_use_runner.py @@ -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":