Merge pull request 'add history endpoint' (#13) from feature/api-history-response into develop

Reviewed-on: #13
This commit is contained in:
fedorkobylkevitch 2026-04-22 12:16:46 +00:00
commit ff1799cd98
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` - `POST /api/browser/tasks` возвращает `202` и `task_id`
- `GET /api/browser/tasks/{task_id}` возвращает `queued/running/...` - `GET /api/browser/tasks/{task_id}` возвращает `queued/running/...`
- `GET /api/browser/tasks/{task_id}/result` возвращает `202`, пока задача не завершена - `GET /api/browser/tasks/{task_id}/result` возвращает `202`, пока задача не завершена
- `GET /api/browser/tasks/{task_id}/history` возвращает историю шагов агента
## Архитектура ## Архитектура
@ -102,6 +103,34 @@ Response `202`:
- `202` если задача еще `queued/running` - `202` если задача еще `queued/running`
- `200` с финальным payload после завершения - `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 пример ## Быстрый end-to-end пример
```zsh ```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"
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID/result" 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="Итоговый текстовый результат") result: str | None = Field(default=None, description="Итоговый текстовый результат")
error: 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") 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 result: str | None = None
error: str | None = None error: str | None = None
raw_response: dict[str, Any] | None = None raw_response: dict[str, Any] | None = None
history: list[dict[str, Any]] = field(default_factory=list)
@property @property
def execution_time(self) -> float: def execution_time(self) -> float:
@ -61,6 +62,7 @@ class TaskStore:
raw_response: dict[str, Any] | None, raw_response: dict[str, Any] | None,
error: str | None, error: str | None,
result: str | None = None, result: str | None = None,
history: list[dict[str, Any]] | None = None,
) -> TaskRecord | None: ) -> TaskRecord | None:
async with self._lock: async with self._lock:
rec = self._tasks.get(task_id) rec = self._tasks.get(task_id)
@ -72,5 +74,6 @@ class TaskStore:
raw_response.get("error") if isinstance(raw_response, dict) else None) raw_response.get("error") if isinstance(raw_response, dict) else None)
rec.result = result if result is not None else ( rec.result = result if result is not None else (
raw_response.get("result") if isinstance(raw_response, dict) else None) 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 rec.status = TaskStatus.succeeded if success else TaskStatus.failed
return rec return rec

View file

@ -3,9 +3,11 @@ from fastapi.responses import JSONResponse
from api.contracts.task_schemas import ( from api.contracts.task_schemas import (
BrowserTaskAcceptedResponse, BrowserTaskAcceptedResponse,
BrowserTaskHistoryResponse,
BrowserTaskRequest, BrowserTaskRequest,
BrowserTaskResultResponse, BrowserTaskResultResponse,
BrowserTaskStatusResponse, BrowserTaskStatusResponse,
TaskHistoryEvent,
) )
from api.domain.task_status import TaskStatus from api.domain.task_status import TaskStatus
from api.repositories.task_store import TaskRecord 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: def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
return BrowserTaskStatusResponse( return BrowserTaskStatusResponse(
task_id=rec.task_id, task_id=rec.task_id,
@ -78,3 +106,24 @@ def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
finished_at=rec.finished_at, finished_at=rec.finished_at,
error=rec.error, 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, raw_response=raw,
error=None, error=None,
result=raw.get("result") if isinstance(raw, dict) else None, result=raw.get("result") if isinstance(raw, dict) else None,
history=self._extract_history(raw),
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self._store.set_done( await self._store.set_done(
@ -66,6 +67,7 @@ class TaskService:
success=False, success=False,
raw_response=None, raw_response=None,
error="Timeout exceeded", error="Timeout exceeded",
history=None,
) )
except BrowserRpcError as exc: except BrowserRpcError as exc:
await self._store.set_done( await self._store.set_done(
@ -73,6 +75,7 @@ class TaskService:
success=False, success=False,
raw_response=None, raw_response=None,
error=str(exc), error=str(exc),
history=None,
) )
except Exception as exc: except Exception as exc:
await self._store.set_done( await self._store.set_done(
@ -80,4 +83,21 @@ class TaskService:
success=False, success=False,
raw_response=None, raw_response=None,
error=f"Internal error: {exc}", 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": { "browser": {
"headless": true, "headless": true,

View file

@ -2,6 +2,7 @@ import asyncio
import json import json
import os import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib import error, request from urllib import error, request
from browser_use import Agent, Browser, ChatOpenAI from browser_use import Agent, Browser, ChatOpenAI
@ -36,6 +37,7 @@ async def run_browser_task(task):
return { return {
"success": True, "success": True,
"result": history.final_result(), "result": history.final_result(),
"history": _extract_history_events(history),
"browser_view": browser_view_url, "browser_view": browser_view_url,
} }
except Exception as err: except Exception as err:
@ -47,6 +49,90 @@ async def run_browser_task(task):
pass 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): class BrowserUseRPCHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
if self.path != "/health": if self.path != "/health":