fix(agent): 保留持久化Run完整内容
This commit is contained in:
@@ -118,7 +118,7 @@ cd frontend
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
当前回归基线为后端 80 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
当前回归基线为后端 81 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||||
|
|
||||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
|||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
当前基线为 80 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
当前基线为 81 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||||
|
|
||||||
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||||
|
|
||||||
|
|||||||
@@ -46,15 +46,17 @@ _BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[^\s,;]+")
|
|||||||
_API_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
|
_API_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
|
||||||
|
|
||||||
|
|
||||||
def sanitize_trace_value(value: Any, *, depth: int = 0) -> Any:
|
def sanitize_trace_value(
|
||||||
"""递归净化 Trace 数据;键名疑似 Secret 时不保留原值。"""
|
value: Any, *, depth: int = 0, apply_limits: bool = True
|
||||||
|
) -> Any:
|
||||||
|
"""递归净化持久化数据;可按审计用途限制体积,Secret 始终脱敏。"""
|
||||||
|
|
||||||
if depth >= MAX_TRACE_DEPTH:
|
if apply_limits and depth >= MAX_TRACE_DEPTH:
|
||||||
return "[MAX_DEPTH]"
|
return "[MAX_DEPTH]"
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
sanitized: dict[str, Any] = {}
|
sanitized: dict[str, Any] = {}
|
||||||
for index, (key, item) in enumerate(value.items()):
|
for index, (key, item) in enumerate(value.items()):
|
||||||
if index >= MAX_TRACE_COLLECTION:
|
if apply_limits and index >= MAX_TRACE_COLLECTION:
|
||||||
sanitized["__truncated__"] = True
|
sanitized["__truncated__"] = True
|
||||||
break
|
break
|
||||||
normalized = str(key).casefold().replace("-", "_")
|
normalized = str(key).casefold().replace("-", "_")
|
||||||
@@ -62,26 +64,33 @@ def sanitize_trace_value(value: Any, *, depth: int = 0) -> Any:
|
|||||||
"[REDACTED]"
|
"[REDACTED]"
|
||||||
if normalized in _SECRET_KEYS
|
if normalized in _SECRET_KEYS
|
||||||
or normalized.endswith(_SECRET_KEY_SUFFIXES)
|
or normalized.endswith(_SECRET_KEY_SUFFIXES)
|
||||||
else sanitize_trace_value(item, depth=depth + 1)
|
else sanitize_trace_value(
|
||||||
|
item, depth=depth + 1, apply_limits=apply_limits
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return sanitized
|
return sanitized
|
||||||
if isinstance(value, (list, tuple)):
|
if isinstance(value, (list, tuple)):
|
||||||
|
source_items = value[:MAX_TRACE_COLLECTION] if apply_limits else value
|
||||||
items = [
|
items = [
|
||||||
sanitize_trace_value(item, depth=depth + 1)
|
sanitize_trace_value(
|
||||||
for item in value[:MAX_TRACE_COLLECTION]
|
item, depth=depth + 1, apply_limits=apply_limits
|
||||||
|
)
|
||||||
|
for item in source_items
|
||||||
]
|
]
|
||||||
if len(value) > MAX_TRACE_COLLECTION:
|
if apply_limits and len(value) > MAX_TRACE_COLLECTION:
|
||||||
items.append("[TRUNCATED]")
|
items.append("[TRUNCATED]")
|
||||||
return items
|
return items
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = _BEARER_PATTERN.sub("Bearer [REDACTED]", value)
|
value = _BEARER_PATTERN.sub("Bearer [REDACTED]", value)
|
||||||
value = _API_KEY_PATTERN.sub("[REDACTED]", value)
|
value = _API_KEY_PATTERN.sub("[REDACTED]", value)
|
||||||
if len(value) > MAX_TRACE_STRING:
|
if apply_limits and len(value) > MAX_TRACE_STRING:
|
||||||
return f"{value[:MAX_TRACE_STRING]}...[TRUNCATED]"
|
return f"{value[:MAX_TRACE_STRING]}...[TRUNCATED]"
|
||||||
return value
|
return value
|
||||||
if value is None or isinstance(value, (str, int, float, bool)):
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
return value
|
return value
|
||||||
return sanitize_trace_value(str(value), depth=depth + 1)
|
return sanitize_trace_value(
|
||||||
|
str(value), depth=depth + 1, apply_limits=apply_limits
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentTraceRepository:
|
class AgentTraceRepository:
|
||||||
@@ -354,6 +363,10 @@ class AgentTraceRepository:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_run(run: AgentRun) -> str:
|
def _serialize_run(run: AgentRun) -> str:
|
||||||
|
# Run 是重启后 GET/list 的完整事实;只做 Secret 脱敏,不套用 Trace 摘要限长。
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
sanitize_trace_value(run.model_dump(mode="json")), ensure_ascii=False
|
sanitize_trace_value(
|
||||||
|
run.model_dump(mode="json"), apply_limits=False
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -242,6 +242,38 @@ def test_trace_redacts_secrets_and_truncates_large_values() -> None:
|
|||||||
run(scenario())
|
run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_persisted_agent_run_preserves_long_input_and_output() -> None:
|
||||||
|
"""审计事件可以限长,但重启后读取的 AgentRun 不能丢失正文。"""
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
long_input = "输入" * 2_500
|
||||||
|
long_output = "输出" * 2_500
|
||||||
|
request = AgentRunCreateRequest(
|
||||||
|
input=long_input,
|
||||||
|
provider_id="mock",
|
||||||
|
model="mock-1",
|
||||||
|
)
|
||||||
|
persisted = AgentRun(
|
||||||
|
run_id="run_long_content",
|
||||||
|
status=AgentRunStatus.completed,
|
||||||
|
input=long_input,
|
||||||
|
output=long_output,
|
||||||
|
provider_id=request.provider_id,
|
||||||
|
model=request.model,
|
||||||
|
max_steps=request.max_steps,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository = AgentTraceRepository()
|
||||||
|
repository.create_run(persisted, request, {"model": request.model})
|
||||||
|
|
||||||
|
restored = repository.get_run(persisted.run_id)
|
||||||
|
|
||||||
|
assert restored is not None
|
||||||
|
assert restored.input == long_input
|
||||||
|
assert restored.output == long_output
|
||||||
|
|
||||||
|
|
||||||
async def _collect_events(iterator):
|
async def _collect_events(iterator):
|
||||||
return [event async for event in iterator]
|
return [event async for event in iterator]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user