feat(agent): 持久化Trace并支持SSE恢复

This commit is contained in:
2026-09-01 00:39:37 +08:00
parent 8da75d4420
commit 3cb197aafe
20 changed files with 949 additions and 70 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test
```
当前回归基线为后端 76 项测试、前端 26 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
当前回归基线为后端 80 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。
+5
View File
@@ -104,6 +104,11 @@ class PermissionManager:
ticket.future.set_result(decision)
return True
def get_ticket(self, run_id: str, request_id: str) -> PermissionTicket | None:
"""只读返回待确认票据,供 Trace 记录权限类型;不暴露 Future 给接口层。"""
return self._pending.get((run_id, request_id))
def cancel_run(self, run_id: str) -> None:
for key, ticket in list(self._pending.items()):
if ticket.run_id == run_id:
+178 -35
View File
@@ -7,17 +7,20 @@ import json
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import datetime, timezone
from time import perf_counter
from typing import TYPE_CHECKING
from uuid import uuid4
from app.agent.permissions import PermissionManager, PermissionMode
from app.agent.tools import ToolExecutionContext, ToolNotFoundError, ToolRegistry
from app.agent.trace_repository import AgentTraceRepository, sanitize_trace_value
from app.contracts import (
AgentEvent,
AgentEventType,
AgentRun,
AgentRunCreateRequest,
AgentRunStatus,
AgentTraceResponse,
Citation,
Message,
MessageRole,
@@ -61,6 +64,7 @@ class RunRecord:
events: list[AgentEvent] = field(default_factory=list)
subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set)
task: asyncio.Task[None] | None = None
next_sequence: int = 0
class AgentRuntime:
@@ -72,11 +76,13 @@ class AgentRuntime:
tools: ToolRegistry,
permissions: PermissionManager,
skills: SkillRuntime | None = None,
trace_repository: AgentTraceRepository | None = None,
) -> None:
self.providers = providers
self.tools = tools
self.permissions = permissions
self.skills = skills
self.trace_repository = trace_repository or AgentTraceRepository()
self._records: dict[str, RunRecord] = {}
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
@@ -116,22 +122,38 @@ class AgentRuntime:
skill_config=skill_config,
allowed_tools=allowed_tools,
)
self.trace_repository.create_run(
run,
request,
self._config_snapshot(record),
)
self._records[run.run_id] = record
record.task = asyncio.create_task(self._execute(record), name=run.run_id)
return run.model_copy(deep=True)
def get_run(self, run_id: str) -> AgentRun:
return self._get_record(run_id).run.model_copy(deep=True)
record = self._records.get(run_id)
if record is not None:
return record.run.model_copy(deep=True)
run = self.trace_repository.recover_interrupted(run_id)
if run is None:
raise AgentRunNotFoundError(run_id)
return run.model_copy(deep=True)
def list_runs(self, limit: int, offset: int) -> tuple[list[AgentRun], int]:
records = sorted(
self._records.values(), key=lambda item: item.run.created_at, reverse=True
)
items = [item.run.model_copy(deep=True) for item in records[offset : offset + limit]]
return items, len(records)
items, total = self.trace_repository.list_runs(limit=limit, offset=offset)
recovered = [
self.trace_repository.recover_interrupted(item.run_id) or item
if item.run_id not in self._records
else self._records[item.run_id].run.model_copy(deep=True)
for item in items
]
return recovered, total
async def cancel(self, run_id: str) -> AgentRun:
record = self._get_record(run_id)
record = self._records.get(run_id)
if record is None:
return self.get_run(run_id)
if record.run.status in TERMINAL_STATUSES:
return record.run.model_copy(deep=True)
record.run.cancelled = True
@@ -144,23 +166,53 @@ class AgentRuntime:
return record.run.model_copy(deep=True)
def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
self._get_record(run_id)
return self.permissions.resolve(run_id, request_id, decision)
record = self._records.get(run_id)
if record is None:
return False
ticket = self.permissions.get_ticket(run_id, request_id)
resolved = self.permissions.resolve(run_id, request_id, decision)
if resolved:
self._publish(
record,
AgentEventType.permission_resolved,
{
"request_id": request_id,
"permission": ticket.permission if ticket else None,
"decision": decision,
},
)
return resolved
async def events(self, run_id: str) -> AsyncIterator[AgentEvent]:
record = self._get_record(run_id)
# 先回放快照再订阅实时事件,使晚加入的 SSE 客户端也能恢复界面状态。
# TODO(agent): 持久化事件并支持 Last-Event-ID,进程重启后仍可续传。
async def events(
self, run_id: str, *, after_sequence: int = -1
) -> AsyncIterator[AgentEvent]:
record = self._records.get(run_id)
run = self.get_run(run_id)
if record is None:
for event in self.trace_repository.list_events(
run_id, after_sequence=after_sequence
):
yield event
return
# 先注册订阅再读持久化历史;同一事件循环内没有 await,不会丢失交界事件。
queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
record.subscribers.add(queue)
history = [event.model_copy(deep=True) for event in record.events]
history = self.trace_repository.list_events(
run_id, after_sequence=after_sequence
)
last_sequence = after_sequence
try:
for event in history:
last_sequence = event.sequence
yield event
if record.run.status in TERMINAL_STATUSES:
if run.status in TERMINAL_STATUSES:
return
while True:
event = await queue.get()
if event.sequence <= last_sequence:
continue
last_sequence = event.sequence
yield event.model_copy(deep=True)
if event.event in {
AgentEventType.run_completed,
@@ -172,7 +224,9 @@ class AgentRuntime:
record.subscribers.discard(queue)
async def wait(self, run_id: str) -> AgentRun:
record = self._get_record(run_id)
record = self._records.get(run_id)
if record is None:
return self.get_run(run_id)
if record.task:
try:
await asyncio.shield(record.task)
@@ -180,6 +234,17 @@ class AgentRuntime:
pass
return record.run.model_copy(deep=True)
def get_trace(
self, run_id: str, *, after_sequence: int, limit: int
) -> AgentTraceResponse:
self.get_run(run_id)
trace = self.trace_repository.get_trace(
run_id, after_sequence=after_sequence, limit=limit
)
if trace is None:
raise AgentRunNotFoundError(run_id)
return trace
async def _execute(self, record: RunRecord) -> None:
try:
async with asyncio.timeout(record.request.run_timeout_seconds):
@@ -210,15 +275,51 @@ class AgentRuntime:
for step in range(1, record.request.max_steps + 1):
record.run.current_step = step
record.run.updated_at = datetime.now(timezone.utc)
turn = await provider.complete(
ModelRequest(
provider_id=record.request.provider_id,
model=record.request.model,
system=(record.skill_config.system_prompt if record.skill_config else None),
messages=messages,
tools=allowed_tools,
metadata=self._request_metadata(record),
model_call_id = f"model_call_{uuid4().hex}"
started_at = perf_counter()
self._publish(
record,
AgentEventType.model_call_started,
{
"model_call_id": model_call_id,
"step": step,
"provider_id": record.request.provider_id,
"model": record.request.model,
},
)
try:
turn = await provider.complete(
ModelRequest(
provider_id=record.request.provider_id,
model=record.request.model,
system=(record.skill_config.system_prompt if record.skill_config else None),
messages=messages,
tools=allowed_tools,
metadata=self._request_metadata(record),
)
)
except Exception as exc:
self._publish(
record,
AgentEventType.model_call_failed,
{
"model_call_id": model_call_id,
"duration_ms": int((perf_counter() - started_at) * 1000),
"error_code": getattr(exc, "code", type(exc).__name__),
},
)
raise
self._publish(
record,
AgentEventType.model_call_completed,
{
"model_call_id": model_call_id,
"duration_ms": int((perf_counter() - started_at) * 1000),
"finish_reason": "tool_calls" if turn.tool_calls else "stop",
"input_tokens": turn.input_tokens,
"output_tokens": turn.output_tokens,
"tool_call_count": len(turn.tool_calls),
},
)
record.run.token_usage += turn.input_tokens + turn.output_tokens
self._publish(
@@ -257,7 +358,7 @@ class AgentRuntime:
async def execute(call: ToolCall) -> ToolResult:
async with semaphore:
return await self._execute_tool(record, call)
return await self._execute_tool(record, call, model_call_id)
results = await asyncio.gather(*(execute(call) for call in calls))
for call, result in zip(calls, results):
@@ -290,8 +391,13 @@ class AgentRuntime:
self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
async def _execute_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
self._publish(record, AgentEventType.tool_call, call.model_dump(mode="json"))
async def _execute_tool(
self, record: RunRecord, call: ToolCall, parent_model_call_id: str
) -> ToolResult:
started_at = perf_counter()
call_data = call.model_dump(mode="json")
call_data["parent_model_call_id"] = parent_model_call_id
self._publish(record, AgentEventType.tool_call, call_data)
try:
registered = self.tools.get(call.name)
except ToolNotFoundError:
@@ -305,7 +411,9 @@ class AgentRuntime:
error_code="TOOL_NOT_ALLOWED",
error_message="Tool is not included in allowed_tools.",
)
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
self._publish_tool_result(
record, result, parent_model_call_id, started_at
)
return result
permission = registered.definition.permission if registered else None
@@ -317,7 +425,9 @@ class AgentRuntime:
error_code="NETWORK_NOT_ALLOWED",
error_message="Agent run does not allow network tools.",
)
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
self._publish_tool_result(
record, result, parent_model_call_id, started_at
)
return result
mode = self.permissions.mode_for(permission)
if mode == PermissionMode.deny:
@@ -348,11 +458,13 @@ class AgentRuntime:
error_code="PERMISSION_TIMEOUT",
error_message="Tool permission confirmation timed out.",
)
self._publish(
record, AgentEventType.tool_result, result.model_dump(mode="json")
self._publish_tool_result(
record, result, parent_model_call_id, started_at
)
return result
record.run.status = AgentRunStatus.running
record.run.updated_at = datetime.now(timezone.utc)
self.trace_repository.save_run(record.run)
result = (
await self._invoke_tool(record, call)
if decision in {"allow_once", "allow_session"}
@@ -361,9 +473,21 @@ class AgentRuntime:
else:
result = await self._invoke_tool(record, call)
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
self._publish_tool_result(record, result, parent_model_call_id, started_at)
return result
def _publish_tool_result(
self,
record: RunRecord,
result: ToolResult,
parent_model_call_id: str,
started_at: float,
) -> None:
data = result.model_dump(mode="json")
data["parent_model_call_id"] = parent_model_call_id
data["duration_ms"] = int((perf_counter() - started_at) * 1000)
self._publish(record, AgentEventType.tool_result, data)
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
try:
return await asyncio.wait_for(
@@ -411,15 +535,19 @@ class AgentRuntime:
def _publish(
self, record: RunRecord, event_type: AgentEventType, data: dict[str, object]
) -> None:
sanitized = sanitize_trace_value(data)
assert isinstance(sanitized, dict)
event = AgentEvent(
event=event_type,
run_id=record.run.run_id,
sequence=len(record.events),
data=data,
sequence=record.next_sequence,
data=sanitized,
timestamp=datetime.now(timezone.utc),
)
record.next_sequence += 1
record.events.append(event)
# 内存事件只保留最近窗口;完整审计轨迹应由后续持久化层承担。
self.trace_repository.append_event(record.run, event)
# 内存只保留实时订阅窗口;完整审计轨迹由 SQLite 保存。
if len(record.events) > MAX_EVENTS_PER_RUN:
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
for queue in record.subscribers:
@@ -433,6 +561,21 @@ class AgentRuntime:
metadata["retrieval"] = record.skill_config.retrieval.model_dump(mode="json")
return metadata
def _config_snapshot(self, record: RunRecord) -> dict[str, object]:
provider = self.providers.get(record.request.provider_id).config
return {
"provider_id": record.request.provider_id,
"provider_type": provider.provider_type.value,
"model": record.request.model,
"capabilities": [item.value for item in provider.capabilities],
"skill_id": record.request.skill_id,
"allowed_tools": list(record.allowed_tools),
"max_steps": record.request.max_steps,
"token_budget": record.request.token_budget,
"allow_network": record.request.allow_network,
"metadata": record.request.metadata,
}
def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
if not result.success or not isinstance(result.output, dict):
return
+359
View File
@@ -0,0 +1,359 @@
"""Agent Run/Event 持久化与 Trace 查询。
SQLite 中的事件是 SSE、前端 Trace 和 Benchmark 的共同事实来源。写入前统一脱敏和
限长,避免 Secret 或无限大的 Tool Result 进入审计数据。
"""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
from app.contracts import (
AgentEvent,
AgentEventType,
AgentRun,
AgentRunCreateRequest,
AgentRunStatus,
AgentTraceResponse,
AgentTraceSummary,
)
from app.database.db import connect, transaction
MAX_TRACE_STRING = 4_096
MAX_TRACE_COLLECTION = 100
MAX_TRACE_DEPTH = 8
_SECRET_KEYS = {
"api_key",
"apikey",
"authorization",
"access_token",
"refresh_token",
"client_secret",
"password",
"secret",
"token",
}
_SECRET_KEY_SUFFIXES = ("_api_key", "_password", "_secret")
_TERMINAL_VALUES = {
AgentRunStatus.completed.value,
AgentRunStatus.failed.value,
AgentRunStatus.cancelled.value,
}
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[^\s,;]+")
_API_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
def sanitize_trace_value(value: Any, *, depth: int = 0) -> Any:
"""递归净化 Trace 数据;键名疑似 Secret 时不保留原值。"""
if depth >= MAX_TRACE_DEPTH:
return "[MAX_DEPTH]"
if isinstance(value, dict):
sanitized: dict[str, Any] = {}
for index, (key, item) in enumerate(value.items()):
if index >= MAX_TRACE_COLLECTION:
sanitized["__truncated__"] = True
break
normalized = str(key).casefold().replace("-", "_")
sanitized[str(key)] = (
"[REDACTED]"
if normalized in _SECRET_KEYS
or normalized.endswith(_SECRET_KEY_SUFFIXES)
else sanitize_trace_value(item, depth=depth + 1)
)
return sanitized
if isinstance(value, (list, tuple)):
items = [
sanitize_trace_value(item, depth=depth + 1)
for item in value[:MAX_TRACE_COLLECTION]
]
if len(value) > MAX_TRACE_COLLECTION:
items.append("[TRUNCATED]")
return items
if isinstance(value, str):
value = _BEARER_PATTERN.sub("Bearer [REDACTED]", value)
value = _API_KEY_PATTERN.sub("[REDACTED]", value)
if len(value) > MAX_TRACE_STRING:
return f"{value[:MAX_TRACE_STRING]}...[TRUNCATED]"
return value
if value is None or isinstance(value, (str, int, float, bool)):
return value
return sanitize_trace_value(str(value), depth=depth + 1)
class AgentTraceRepository:
def create_run(
self,
run: AgentRun,
request: AgentRunCreateRequest,
config_snapshot: dict[str, Any],
) -> None:
conn = connect()
try:
with transaction(conn):
conn.execute(
"""
INSERT INTO agent_runs(
run_id, status, run_json, request_json, config_snapshot_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
run.run_id,
run.status.value,
self._serialize_run(run),
json.dumps(
sanitize_trace_value(request.model_dump(mode="json")),
ensure_ascii=False,
),
json.dumps(
sanitize_trace_value(config_snapshot), ensure_ascii=False
),
run.created_at.isoformat(),
run.updated_at.isoformat(),
),
)
finally:
conn.close()
def save_run(self, run: AgentRun) -> None:
conn = connect()
try:
with transaction(conn):
self._update_run(conn, run)
finally:
conn.close()
def append_event(self, run: AgentRun, event: AgentEvent) -> None:
"""在同一事务中保存最新 Run 和事件;复写同一序号时保持幂等。"""
conn = connect()
try:
with transaction(conn):
self._update_run(conn, run)
conn.execute(
"""
INSERT INTO agent_events(run_id, sequence, event, data_json, timestamp)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(run_id, sequence) DO NOTHING
""",
(
event.run_id,
event.sequence,
event.event.value,
json.dumps(event.data, ensure_ascii=False),
event.timestamp.isoformat(),
),
)
finally:
conn.close()
def get_run(self, run_id: str) -> AgentRun | None:
conn = connect()
try:
row = conn.execute(
"SELECT run_json FROM agent_runs WHERE run_id = ?", (run_id,)
).fetchone()
return AgentRun.model_validate_json(row["run_json"]) if row else None
finally:
conn.close()
def list_runs(self, limit: int, offset: int) -> tuple[list[AgentRun], int]:
conn = connect()
try:
total = int(conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0])
rows = conn.execute(
"""
SELECT run_json FROM agent_runs
ORDER BY created_at DESC LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [AgentRun.model_validate_json(row["run_json"]) for row in rows], total
finally:
conn.close()
def list_events(
self, run_id: str, *, after_sequence: int = -1, limit: int | None = None
) -> list[AgentEvent]:
conn = connect()
try:
sql = """
SELECT event, sequence, data_json, timestamp
FROM agent_events
WHERE run_id = ? AND sequence > ?
ORDER BY sequence
"""
params: tuple[Any, ...] = (run_id, after_sequence)
if limit is not None:
sql += " LIMIT ?"
params += (limit,)
return [self._event_from_row(run_id, row) for row in conn.execute(sql, params)]
finally:
conn.close()
def get_trace(
self, run_id: str, *, after_sequence: int, limit: int
) -> AgentTraceResponse | None:
conn = connect()
try:
row = conn.execute(
"""
SELECT run_json, config_snapshot_json
FROM agent_runs WHERE run_id = ?
""",
(run_id,),
).fetchone()
if row is None:
return None
run = AgentRun.model_validate_json(row["run_json"])
event_rows = conn.execute(
"""
SELECT event, sequence, data_json, timestamp
FROM agent_events
WHERE run_id = ? AND sequence > ?
ORDER BY sequence LIMIT ?
""",
(run_id, after_sequence, limit + 1),
).fetchall()
has_more = len(event_rows) > limit
items = [
self._event_from_row(run_id, item) for item in event_rows[:limit]
]
counts = {
item["event"]: int(item["count"])
for item in conn.execute(
"""
SELECT event, COUNT(*) AS count
FROM agent_events WHERE run_id = ? GROUP BY event
""",
(run_id,),
)
}
tool_errors = int(
conn.execute(
"""
SELECT COUNT(*) FROM agent_events
WHERE run_id = ? AND event = 'ToolResult'
AND json_extract(data_json, '$.success') = 0
""",
(run_id,),
).fetchone()[0]
)
errors = (
counts.get(AgentEventType.run_failed.value, 0)
+ counts.get(AgentEventType.model_call_failed.value, 0)
+ tool_errors
)
duration_ms = max(
0, int((run.updated_at - run.created_at).total_seconds() * 1000)
)
return AgentTraceResponse(
run_id=run_id,
status=run.status,
items=items,
next_sequence=items[-1].sequence if items else after_sequence,
has_more=has_more,
summary=AgentTraceSummary(
model_calls=counts.get(AgentEventType.model_call_started.value, 0),
tool_calls=counts.get(AgentEventType.tool_call.value, 0),
duration_ms=duration_ms,
token_usage=run.token_usage,
errors=errors,
),
config_snapshot=json.loads(row["config_snapshot_json"]),
)
finally:
conn.close()
def recover_interrupted(self, run_id: str) -> AgentRun | None:
"""把上个进程遗留的非终态 Run 收束为失败,并追加可回放终止事件。"""
conn = connect()
try:
with transaction(conn):
row = conn.execute(
"SELECT run_json, status FROM agent_runs WHERE run_id = ?", (run_id,)
).fetchone()
if row is None:
return None
run = AgentRun.model_validate_json(row["run_json"])
if row["status"] in _TERMINAL_VALUES:
return run
run.status = AgentRunStatus.failed
run.error_code = "AGENT_PROCESS_RESTARTED"
run.error_message = "Agent process restarted before the run completed."
run.updated_at = datetime.now(timezone.utc)
next_sequence = int(
conn.execute(
"""
SELECT COALESCE(MAX(sequence), -1) + 1
FROM agent_events WHERE run_id = ?
""",
(run_id,),
).fetchone()[0]
)
event = AgentEvent(
event=AgentEventType.run_failed,
run_id=run_id,
sequence=next_sequence,
data={
"code": run.error_code,
"message": run.error_message,
},
timestamp=run.updated_at,
)
self._update_run(conn, run)
conn.execute(
"""
INSERT INTO agent_events(run_id, sequence, event, data_json, timestamp)
VALUES (?, ?, ?, ?, ?)
""",
(
run_id,
next_sequence,
event.event.value,
json.dumps(event.data, ensure_ascii=False),
event.timestamp.isoformat(),
),
)
return run
finally:
conn.close()
@staticmethod
def _update_run(conn, run: AgentRun) -> None:
cursor = conn.execute(
"""
UPDATE agent_runs
SET status = ?, run_json = ?, updated_at = ?
WHERE run_id = ?
""",
(
run.status.value,
AgentTraceRepository._serialize_run(run),
run.updated_at.isoformat(),
run.run_id,
),
)
if cursor.rowcount != 1:
raise LookupError(run.run_id)
@staticmethod
def _event_from_row(run_id: str, row) -> AgentEvent:
return AgentEvent(
event=AgentEventType(row["event"]),
run_id=run_id,
sequence=int(row["sequence"]),
data=json.loads(row["data_json"]),
timestamp=datetime.fromisoformat(row["timestamp"]),
)
@staticmethod
def _serialize_run(run: AgentRun) -> str:
return json.dumps(
sanitize_trace_value(run.model_dump(mode="json")), ensure_ascii=False
)
+22
View File
@@ -329,6 +329,10 @@ class AgentEventType(str, Enum):
permission_required = "PermissionRequired"
usage = "Usage"
citation = "Citation"
model_call_started = "ModelCallStarted"
model_call_completed = "ModelCallCompleted"
model_call_failed = "ModelCallFailed"
permission_resolved = "PermissionResolved"
run_completed = "RunCompleted"
run_failed = "RunFailed"
run_cancelled = "RunCancelled"
@@ -342,6 +346,24 @@ class AgentEvent(Contract):
timestamp: datetime
class AgentTraceSummary(Contract):
model_calls: int = 0
tool_calls: int = 0
duration_ms: int = 0
token_usage: int = 0
errors: int = 0
class AgentTraceResponse(Contract):
run_id: str
status: AgentRunStatus
items: list[AgentEvent] = Field(default_factory=list)
next_sequence: int
has_more: bool = False
summary: AgentTraceSummary = Field(default_factory=AgentTraceSummary)
config_snapshot: dict[str, Any] = Field(default_factory=dict)
class PermissionDecisionRequest(Contract):
decision: Literal["allow_once", "allow_session", "deny"]
+27
View File
@@ -69,6 +69,33 @@ MIGRATIONS: list[str] = [
);
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
""",
# v3: 第二阶段 Agent TraceRun 与事件事实持久化,供 SSE 恢复和 Benchmark 复用。
"""
CREATE TABLE IF NOT EXISTS agent_runs (
run_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
run_json TEXT NOT NULL,
request_json TEXT NOT NULL,
config_snapshot_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_runs_created
ON agent_runs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_agent_runs_status
ON agent_runs(status, updated_at DESC);
CREATE TABLE IF NOT EXISTS agent_events (
run_id TEXT NOT NULL REFERENCES agent_runs(run_id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
event TEXT NOT NULL,
data_json TEXT NOT NULL DEFAULT '{}',
timestamp TEXT NOT NULL,
PRIMARY KEY (run_id, sequence)
);
CREATE INDEX IF NOT EXISTS idx_agent_events_type
ON agent_events(run_id, event, sequence);
""",
]
+57 -6
View File
@@ -2,13 +2,14 @@ from collections.abc import AsyncIterator
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Query
from fastapi import APIRouter, Header, Query
from fastapi.responses import StreamingResponse
from app.contracts import (
AgentRun,
AgentRunCreateRequest,
AgentRunListResponse,
AgentTraceResponse,
ChatRequest,
CredentialStatus,
CredentialWriteRequest,
@@ -81,8 +82,9 @@ def utc_now() -> datetime:
return datetime.now(timezone.utc)
def as_sse(event: str, payload: str) -> str:
return f"event: {event}\ndata: {payload}\n\n"
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
id_line = f"id: {event_id}\n" if event_id is not None else ""
return f"{id_line}event: {event}\ndata: {payload}\n\n"
def provider_or_404(provider_id: str):
@@ -314,16 +316,65 @@ async def cancel_agent_run(run_id: str) -> OperationResponse:
},
tags=["Agent"],
)
async def agent_events(run_id: str) -> StreamingResponse:
async def agent_events(
run_id: str,
after_sequence: int | None = Query(default=None, ge=-1),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
) -> StreamingResponse:
agent_run_or_404(run_id)
cursor = after_sequence
if cursor is None and last_event_id is not None:
try:
cursor = int(last_event_id)
except ValueError as exc:
raise ApiError(
400,
"TRACE_CURSOR_INVALID",
"Last-Event-ID must be an integer sequence.",
{"last_event_id": last_event_id},
) from exc
if cursor < -1:
raise ApiError(
400,
"TRACE_CURSOR_INVALID",
"Last-Event-ID must be greater than or equal to -1.",
)
cursor = cursor if cursor is not None else -1
async def stream() -> AsyncIterator[str]:
async for event in container.agent.events(run_id):
yield as_sse(event.event.value, event.model_dump_json())
async for event in container.agent.events(run_id, after_sequence=cursor):
yield as_sse(
event.event.value,
event.model_dump_json(),
event_id=event.sequence,
)
return StreamingResponse(stream(), media_type="text/event-stream")
@router.get(
"/agent/runs/{run_id}/trace",
response_model=AgentTraceResponse,
tags=["Agent"],
)
async def get_agent_trace(
run_id: str,
after_sequence: int = Query(default=-1, ge=-1),
limit: int = Query(default=200, ge=1, le=500),
) -> AgentTraceResponse:
try:
return container.agent.get_trace(
run_id, after_sequence=after_sequence, limit=limit
)
except AgentRunNotFoundError as exc:
raise ApiError(
404,
"AGENT_RUN_NOT_FOUND",
f"Agent run does not exist: {run_id}",
{"run_id": run_id},
) from exc
@router.post(
"/agent/runs/{run_id}/permissions/{request_id}",
response_model=OperationResponse,
+168
View File
@@ -1,10 +1,18 @@
import asyncio
from datetime import datetime, timezone
import pytest
from app.agent.trace_repository import AgentTraceRepository
from app.agent.permissions import PermissionMode
from app.agent.tools import ToolExecutionContext
from app.container import build_container
from app.database.db import connect
from app.errors import ApiError
from app.routes import agent_events
from app.contracts import (
AgentEventType,
AgentRun,
AgentRunCreateRequest,
AgentRunStatus,
ToolCall,
@@ -108,8 +116,168 @@ def test_permission_confirmation_resumes_agent() -> None:
created.run_id, request_id, "allow_once"
)
completed = await container.agent.wait(created.run_id)
events = [event async for event in container.agent.events(created.run_id)]
assert completed.status == AgentRunStatus.completed
assert completed.tool_results[0].success is True
assert AgentEventType.permission_resolved in {event.event for event in events}
run(scenario())
def test_agent_trace_persists_and_replays_from_sequence() -> None:
async def scenario() -> None:
first = build_container()
created = await first.agent.create_run(
AgentRunCreateRequest(
input="persistent trace",
provider_id="mock",
model="mock-1",
metadata={"suite": "agent-benchmark-v1"},
)
)
completed = await first.agent.wait(created.run_id)
restarted = build_container()
restored = restarted.agent.get_run(created.run_id)
first_page = restarted.agent.get_trace(
created.run_id, after_sequence=-1, limit=2
)
second_page = restarted.agent.get_trace(
created.run_id,
after_sequence=first_page.next_sequence,
limit=100,
)
replay = [
event
async for event in restarted.agent.events(
created.run_id, after_sequence=first_page.next_sequence
)
]
assert completed.status == restored.status == AgentRunStatus.completed
assert first_page.has_more is True
assert [item.sequence for item in first_page.items] == [0, 1]
assert second_page.items[0].sequence == 2
assert replay == second_page.items
assert first_page.summary.model_calls == 1
assert first_page.summary.token_usage == completed.token_usage
assert first_page.config_snapshot["metadata"] == {
"suite": "agent-benchmark-v1"
}
assert second_page.items[-1].event == AgentEventType.run_completed
run(scenario())
def test_interrupted_persisted_run_is_closed_after_restart() -> None:
now = datetime.now(timezone.utc)
request = AgentRunCreateRequest(
input="interrupted",
provider_id="mock",
model="mock-1",
)
persisted = AgentRun(
run_id="run_interrupted",
status=AgentRunStatus.running,
input=request.input,
provider_id=request.provider_id,
model=request.model,
max_steps=request.max_steps,
created_at=now,
updated_at=now,
)
AgentTraceRepository().create_run(persisted, request, {"model": "mock-1"})
restarted = build_container()
recovered = restarted.agent.get_run(persisted.run_id)
events = run(
_collect_events(restarted.agent.events(persisted.run_id, after_sequence=-1))
)
assert recovered.status == AgentRunStatus.failed
assert recovered.error_code == "AGENT_PROCESS_RESTARTED"
assert events[-1].event == AgentEventType.run_failed
assert events[-1].sequence == 0
def test_trace_redacts_secrets_and_truncates_large_values() -> None:
async def scenario() -> None:
container = build_container()
secret = "sk-should-not-be-stored"
created = await container.agent.create_run(
AgentRunCreateRequest(
input=f'/tool system.echo {{"text":"{"x" * 4200}","api_key":"{secret}"}}',
provider_id="mock",
model="mock-1",
allowed_tools=["system.echo"],
metadata={"authorization": secret},
)
)
await container.agent.wait(created.run_id)
trace = container.agent.get_trace(
created.run_id, after_sequence=-1, limit=100
)
tool_call = next(
item for item in trace.items if item.event == AgentEventType.tool_call
)
assert tool_call.data["arguments"]["api_key"] == "[REDACTED]"
assert str(tool_call.data["arguments"]["text"]).endswith("...[TRUNCATED]")
assert trace.config_snapshot["metadata"]["authorization"] == "[REDACTED]"
assert secret not in trace.model_dump_json()
conn = connect()
try:
stored_row = conn.execute(
"""
SELECT run_json, request_json, config_snapshot_json
FROM agent_runs WHERE run_id = ?
""",
(created.run_id,),
).fetchone()
stored = "\n".join(str(value) for value in stored_row)
finally:
conn.close()
assert secret not in stored
run(scenario())
async def _collect_events(iterator):
return [event async for event in iterator]
def test_agent_sse_uses_last_event_id_and_emits_event_ids(monkeypatch) -> None:
async def scenario() -> None:
test_container = build_container()
monkeypatch.setattr("app.routes.container", test_container)
created = await test_container.agent.create_run(
AgentRunCreateRequest(
input="resume sse",
provider_id="mock",
model="mock-1",
)
)
await test_container.agent.wait(created.run_id)
response = await agent_events(
created.run_id, after_sequence=None, last_event_id="1"
)
chunks = [chunk async for chunk in response.body_iterator]
body = "".join(
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
for chunk in chunks
)
assert "id: 0\n" not in body
assert "id: 1\n" not in body
assert "id: 2\n" in body
assert "event: RunCompleted" in body
with pytest.raises(ApiError) as error:
await agent_events(
created.run_id, after_sequence=None, last_event_id="invalid"
)
assert error.value.code == "TRACE_CURSOR_INVALID"
run(scenario())
+1
View File
@@ -89,6 +89,7 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
"/api/agent/runs",
"/api/agent/runs/{run_id}/cancel",
"/api/agent/runs/{run_id}/events",
"/api/agent/runs/{run_id}/trace",
"/api/skills",
"/api/plugins",
"/api/plugins/install",
+8 -4
View File
@@ -2,7 +2,7 @@
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
> 更新日期:2026-08-30。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成,后端当前回归基线为 71 项测试通过。
> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化和可恢复 SSE 已落地,后端当前回归基线为 80 项测试通过。
## 当前实现
@@ -28,6 +28,7 @@ backend/app/
│ └── mock.py 离线开发 Provider
├── agent/
│ ├── runtime.py Agent Loop、限制、取消、Trace 和 SSE
│ ├── trace_repository.py Run/Event SQLite 持久化、分页、摘要与脱敏
│ ├── tools.py Tool 注册、参数校验、隔离执行和结果转换
│ ├── permissions.py 权限策略、确认请求和会话授权
│ └── builtin_tools.py 无副作用的内置开发 Tool
@@ -53,7 +54,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider
- Permission
- Step、Timeout、Token Budget、取消;
- Tool 并发上限与 run 级网络权限;
- 内存 Trace 与 SSE
- SQLite Trace、分页快照与可恢复 SSE
- Skill Manifest、Prompt、Tool/Permission/模型能力解析;
- Plugin Manifest、生命周期和 Tool Contribution
- Skill 调用内置 Tool 与 Plugin Tool
@@ -171,10 +172,13 @@ POST /api/agent/runs
```text
GET /api/agent/runs/{run_id}
GET /api/agent/runs/{run_id}/events
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
POST /api/agent/runs/{run_id}/cancel
```
当前 Run 与 Trace 保存在内存中,AI Core 重启后清空。Runtime 最多保留 200 个 Run,每个 Run 最多保留 2000 个事件,并限制单轮 Tool Call 数量,避免长时间运行时无界增长。后续数据库层接入时替换 Repository,不改变 API Contract
Run 与 AgentEvent 已写入 SQLite`run_id + sequence` 是幂等键。SSE 每帧包含 `id: sequence`;客户端可以通过 `Last-Event-ID` 请求头或 `after_sequence` 查询参数恢复缺失事件。Trace API 返回平铺事件、下一游标、分页状态、模型/工具调用统计、耗时、Token Usage 和创建 Run 时的配置快照,不负责生成前端树形布局
运行时内存仍只保留最近 2000 个事件用于实时订阅,完整 Trace 以 SQLite 为准。AI Core 重启后,已经终止的 Run 可以继续查询和回放;重启前未终止的 Run 会收束为 `AGENT_PROCESS_RESTARTED`,避免永久停在 `running`。API Key、Authorization、Password、Secret、常见 `sk-`/Bearer 值在入库前脱敏,超长字符串和集合会截断。
## Tool Calling
@@ -333,7 +337,7 @@ Skill Manifest
- 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama AdapterOpenAI Responses 和 Anthropic Messages 尚未实现。
- Provider 配置暂存内存,后续通过 Repository 接入 SQLitePATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
- Run/Trace 暂存内存;下一步抽象 Repository 接入 SQLite。
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
- Task 已持久化到 SQLiteAttachment Tool 读取 Host 管理目录中的 UTF-8 文件。
- `audio.transcribe` 当前消费 Host 预生成的 transcriptfaster-whisper 与说话人分离仍按技术基线在第二阶段接入。
@@ -5,7 +5,7 @@
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
> 实施状态更新:2026-08-31。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault第二阶段在现有边界上接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Agent Trace、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
> 实施状态更新:2026-09-01。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault第二阶段 Agent Trace 持久化、分页快照和可恢复 SSE 已完成。后续继续接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
---
@@ -545,7 +545,7 @@ index_jobs
sync_state
```
其中 `notes``blocks` 保存 Markdown 的结构化投影;FTS5 建立全文索引;sqlite-vec 保存 Block 向量;`agent_runs``tool_calls` 保存 Agent TraceProvider 表保存非敏感模型配置。
其中 `notes``blocks` 保存 Markdown 的结构化投影;FTS5 建立全文索引;sqlite-vec 保存 Block 向量;当前实现以 `agent_runs``agent_events` 保存 Agent TraceProvider 表保存非敏感模型配置。
API Key、同步 Token 等机密数据不进入 SQLite,通过 `credential_id` 与 Stronghold 中的实际密钥关联。
@@ -773,7 +773,7 @@ Agent Run 至少提供以下限制:
- 网络访问权限;
- 并发 Tool 数量。
Agent 运行过程中产生的每一步写入 `agent_runs``tool_calls`,用户可以在 Agent Trace 中查看工具名称、参数摘要、耗时、执行结果和权限状态。
Agent 运行过程中产生的每一步写入 `agent_runs``agent_events`,用户可以在 Agent Trace 中查看工具名称、参数摘要、耗时、执行结果和权限状态。SSE 与 Benchmark 均从同一事件事实读取,不维护旁路数据。
### 10.3 Tool Registry
@@ -2321,7 +2321,7 @@ Markdown Workspace
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
截至 2026-08-31,上述第一阶段后端链路和 Web 联调前端均已完成,第二阶段前置的 Workspace 去 Mock 联调也已完成。当前验证基线为后端 76 项测试、前端 26 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成,第二阶段前置的 Workspace 去 Mock 联调及 Agent Trace 持久化/恢复接口也已完成。当前验证基线为后端 80 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
第二阶段在既有 Contract 上接入:
@@ -2405,7 +2405,7 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启
## 25. 当前技术基线摘要
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引任务Agent TraceProvider/Extension Registry 当前仍为内存实现。
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引任务Agent TraceProvider/Extension Registry 当前仍为内存实现。
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 当前通过 FTS5、`HashEmbeddingProvider`、sqlite-vec、RRF 和轻量 Reranker 跑通混合检索,真实 Embedding 与正式 Benchmark 在第二阶段接入;Agent Runtime 使用 Tool Registry 操作知识库和任务,并将扩展 Agent Trace Contract 供可视化和 Benchmark 共用;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。
+3 -3
View File
@@ -186,13 +186,13 @@ pnpm build
```text
pnpm build passed
pnpm test 26 passed
uv run pytest 76 passed
pnpm test 27 passed
uv run pytest 80 passed
preview smoke HTTP 200
git diff --check passed
```
当前前端使用 Vitest 执行 Store、Workspace API Adapter、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 76 项测试结果,也不涉及产品代码。
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 80 项测试结果,也不涉及产品代码。
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
+4 -2
View File
@@ -55,7 +55,8 @@ Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault
| POST | `/api/agent/runs` | 创建 Agent Run |
| GET | `/api/agent/runs/{run_id}` | 获取 Agent Run 状态与 Trace 摘要 |
| POST | `/api/agent/runs/{run_id}/cancel` | 取消 Agent Run |
| GET | `/api/agent/runs/{run_id}/events` | 订阅 AgentEvent SSE |
| GET | `/api/agent/runs/{run_id}/events` | 订阅 AgentEvent SSE,支持 `Last-Event-ID` / `after_sequence` 恢复 |
| GET | `/api/agent/runs/{run_id}/trace` | 分页读取持久化 Trace、摘要和运行配置快照 |
| POST | `/api/agent/runs/{run_id}/permissions/{request_id}` | 响应 Tool 权限确认 |
| GET | `/api/tools` | 获取已注册 Tool Definition |
@@ -173,9 +174,10 @@ RunCancelled
## 当前实现状态
更新至 2026-08-31:后端 76 项回归测试通过。
更新至 2026-09-01:后端 80 项回归测试通过。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Agent Run/Event 已持久化到 SQLiteSSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
+3 -3
View File
@@ -68,7 +68,7 @@ uv run pytest -q -p no:cacheprovider
当前基线:
```text
76 passed
80 passed
```
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
@@ -83,8 +83,8 @@ pnpm test
当前基线:
```text
10 test files passed
26 tests passed
11 test files passed
27 tests passed
```
通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。
+8 -4
View File
@@ -43,8 +43,8 @@
| Transcription | GET | `/api/media/transcriptions/{job_id}/events` | 计划新增 | 订阅模型加载、分离和转写进度 |
| Transcription | POST | `/api/media/transcriptions/{job_id}/cancel` | 计划新增 | 取消音频任务 |
| Transcription | POST | `/api/media/transcriptions/{job_id}/notes` | 计划新增 | 将 Transcript 写入 Knowledge Core |
| Agent Trace | GET | `/api/agent/runs/{run_id}/events` | 扩展 | 支持游标恢复并增加模型与权限事件 |
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 计划新增 | 分页读取可回放 Trace 快照 |
| Agent Trace | GET | `/api/agent/runs/{run_id}/events` | 已实现 | 支持游标恢复并增加模型与权限事件 |
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 计划新增 | 获取 MCP Host 健康状态 |
| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 计划新增 | 重启异常 Host 并重新发现 Tool |
| Plugin Command | GET | `/api/plugin-contributions/commands` | 计划新增 | 获取前端可展示的 Command |
@@ -310,6 +310,7 @@ RunCancelled
```text
ModelCallStarted
ModelCallCompleted
ModelCallFailed
PermissionResolved
```
@@ -330,8 +331,10 @@ PermissionResolved
"model_calls": 2,
"tool_calls": 3,
"duration_ms": 1530,
"token_usage": 2048
}
"token_usage": 2048,
"errors": 0
},
"config_snapshot": {}
}
```
@@ -388,6 +391,7 @@ ToolCall 和 ToolResult 增加可选 `parent_model_call_id`、`duration_ms` 和
AGENT_RUN_NOT_FOUND
TRACE_NOT_AVAILABLE
TRACE_CURSOR_EXPIRED
TRACE_CURSOR_INVALID
```
---
+22
View File
@@ -143,6 +143,10 @@ export type AgentEventType =
| 'PermissionRequired'
| 'Usage'
| 'Citation'
| 'ModelCallStarted'
| 'ModelCallCompleted'
| 'ModelCallFailed'
| 'PermissionResolved'
| 'RunCompleted'
| 'RunFailed'
| 'RunCancelled'
@@ -155,6 +159,24 @@ export interface AgentEvent {
timestamp: string
}
export interface AgentTraceSummary {
model_calls: number
tool_calls: number
duration_ms: number
token_usage: number
errors: number
}
export interface AgentTraceResponse {
run_id: string
status: AgentRunStatus
items: AgentEvent[]
next_sequence: number
has_more: boolean
summary: AgentTraceSummary
config_snapshot: Record<string, unknown>
}
export interface ToolCall {
tool_call_id: string
name: string
+8
View File
@@ -18,6 +18,10 @@ const eventLabels: Record<AgentEventType, string> = {
PermissionRequired: '请求权限',
Usage: '用量统计',
Citation: '引用来源',
ModelCallStarted: '模型调用开始',
ModelCallCompleted: '模型调用完成',
ModelCallFailed: '模型调用失败',
PermissionResolved: '权限已处理',
RunCompleted: '运行完成',
RunFailed: '运行失败',
RunCancelled: '运行取消',
@@ -86,6 +90,10 @@ const detailLabels: Record<string, string> = {
total_tokens: '令牌总数',
status: '状态',
duration_ms: '耗时(毫秒)',
model_call_id: '模型调用 ID',
parent_model_call_id: '上级模型调用 ID',
finish_reason: '结束原因',
decision: '授权决定',
}
export function runStatusLabel(status?: AgentRunStatus): string {
+12 -3
View File
@@ -1,6 +1,6 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
function toAgentRun(run: ApiAgentRun): AgentRun {
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
@@ -54,6 +54,13 @@ export async function cancelAgentRun(runId: string): Promise<OperationResponse>
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
}
export async function getAgentTrace(
runId: string,
params?: { after_sequence?: number; limit?: number },
): Promise<AgentTraceResponse> {
return apiClient.get(`/api/agent/runs/${runId}/trace`, { params })
}
export async function listTools(): Promise<ToolDefinition[]> {
const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
return response.items
@@ -66,12 +73,14 @@ export function streamAgentEvents(
onError?: (error: Error) => void
onDone?: () => void
onOpen?: () => void
}
},
afterSequence = -1,
): SseClient {
// 将通用 SSE 包装成领域事件,Store 无需了解传输层 envelope。
const client = new SseClient({
url: `/api/agent/runs/${runId}/events`,
url: `/api/agent/runs/${runId}/events?after_sequence=${afterSequence}`,
method: 'GET',
lastEventId: afterSequence >= 0 ? String(afterSequence) : undefined,
onEvent: (eventName, data) => {
handlers.onEvent?.({
event: eventName as AgentEvent['event'],
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SseClient } from './sseClient'
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('SseClient resumable event transport', () => {
it('sends Last-Event-ID and exposes the returned SSE id', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
'id: 3\nevent: ModelCallCompleted\ndata: {"sequence":3,"data":{"duration_ms":12}}\n\n',
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
),
)
vi.stubGlobal('fetch', fetchMock)
const received = vi.fn()
const client = new SseClient({
url: '/api/agent/runs/run-1/events?after_sequence=2',
method: 'GET',
lastEventId: '2',
onEvent: received,
})
await client.connect()
expect(fetchMock).toHaveBeenCalledWith(
'/api/agent/runs/run-1/events?after_sequence=2',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({ 'Last-Event-ID': '2' }),
}),
)
expect(received).toHaveBeenCalledWith(
'ModelCallCompleted',
{ sequence: 3, data: { duration_ms: 12 } },
'3',
)
})
})
+16 -4
View File
@@ -1,12 +1,17 @@
import { resolveApiUrl } from './apiClient'
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
export type SseEventHandler = (
event: string,
data: Record<string, unknown>,
eventId?: string,
) => void
export interface SseClientOptions {
url: string
method?: string
body?: unknown
token?: string
lastEventId?: string
onEvent?: SseEventHandler
onError?: (error: Error) => void
onOpen?: () => void
@@ -26,7 +31,7 @@ export class SseClient {
}
async connect() {
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
const { url, method = 'POST', body, token, lastEventId, onEvent, onError, onOpen, onDone } = this.options
try {
const headers: Record<string, string> = {
@@ -38,6 +43,9 @@ export class SseClient {
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
if (lastEventId !== undefined) {
headers['Last-Event-ID'] = lastEventId
}
const resp = await fetch(resolveApiUrl(url), {
method,
@@ -57,17 +65,19 @@ export class SseClient {
// 一个 UTF-8 字符或 SSE 行可能横跨多个网络分片,必须累积后再按空行派发。
const decoder = new TextDecoder('utf-8')
let eventName = 'message'
let eventId: string | undefined
let dataLines: string[] = []
let doneNotified = false
const dispatchEvent = () => {
if (!dataLines.length) {
eventName = 'message'
eventId = undefined
return
}
try {
const data = JSON.parse(dataLines.join('\n')) as Record<string, unknown>
onEvent?.(eventName, data)
onEvent?.(eventName, data, eventId)
if (!doneNotified && ['Done', 'RunCompleted', 'RunFailed', 'RunCancelled'].includes(eventName)) {
doneNotified = true
onDone?.()
@@ -76,6 +86,7 @@ export class SseClient {
onError?.(error instanceof Error ? error : new Error('Malformed SSE data'))
}
eventName = 'message'
eventId = undefined
dataLines = []
}
@@ -87,6 +98,7 @@ export class SseClient {
let fieldValue = separator === -1 ? '' : line.slice(separator + 1)
if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1)
if (field === 'event') eventName = fieldValue
if (field === 'id') eventId = fieldValue
if (field === 'data') dataLines.push(fieldValue)
}
@@ -118,7 +130,7 @@ export class SseClient {
this.controller.abort()
}
// TODO(streaming): Agent 事件持久化后,增加 Last-Event-ID 与指数退避重连。
// TODO(streaming): 桌面网络策略确定后,在 Store 层增加有上限的指数退避重连。
isConnected() {
return this.connected