fix(backend): 修复全面审阅发现的核心问题

修复索引首次失败回滚、Vault 扫描边界、Markdown 代码围栏和 UTF-16 Citation 偏移。

收紧 Plugin 权限与 JSON Schema 校验,补齐 Note Move、Task、Attachment 和 Transcript Tool。

接入 OpenAI SSE 与 Ollama JSONL 真流式输出,修正 Provider PATCH 语义并限制运行时内存保留。

新增对应回归测试,后端测试增至 62 项。
This commit is contained in:
2026-08-28 09:56:07 +08:00
parent 167ae24796
commit 36bc1022f1
26 changed files with 1531 additions and 99 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
from app.agent.permissions import PermissionManager, PermissionMode, PermissionPolicy
from app.agent.runtime import AgentRuntime, AgentRunNotFoundError
from app.agent.runtime import AgentCapacityError, AgentRuntime, AgentRunNotFoundError
from app.agent.tools import ToolRegistry
__all__ = [
"AgentRunNotFoundError",
"AgentCapacityError",
"AgentRuntime",
"PermissionManager",
"PermissionMode",
+121 -1
View File
@@ -1,9 +1,12 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.agent.tools import ToolExecutionContext, ToolRegistry
from app.contracts import SearchMode, SearchRequest, ToolDefinition
from app.contracts import SearchMode, SearchRequest, TaskStatus, ToolDefinition
from app.retrieval.engine import engine
from app.services import note_service
from app.services import attachment_service, task_service, transcription_service
class ToolArguments(BaseModel):
@@ -54,6 +57,42 @@ class NoteListArguments(ToolArguments):
tag: str | None = None
class NoteMoveArguments(ToolArguments):
note_id: str = Field(min_length=1)
folder: str
class TaskCreateArguments(ToolArguments):
title: str = Field(min_length=1)
description: str = ""
note_id: str | None = None
due_at: datetime | None = None
class TaskUpdateArguments(ToolArguments):
task_id: str = Field(min_length=1)
title: str | None = None
description: str | None = None
status: TaskStatus | None = None
note_id: str | None = None
due_at: datetime | None = None
class TaskListArguments(ToolArguments):
limit: int = Field(default=50, ge=1, le=100)
offset: int = Field(default=0, ge=0)
class AttachmentReadArguments(ToolArguments):
attachment_id: str = Field(min_length=1)
max_chars: int = Field(default=100_000, ge=1, le=1_000_000)
class AudioTranscribeArguments(ToolArguments):
attachment_id: str = Field(min_length=1)
language: str | None = None
async def echo(arguments: EchoArguments, _: ToolExecutionContext) -> dict[str, str]:
return {"text": arguments.text}
@@ -94,6 +133,39 @@ def list_notes(arguments: NoteListArguments, _: ToolExecutionContext) -> dict:
}
async def move_note(arguments: NoteMoveArguments, _: ToolExecutionContext) -> dict:
note = await note_service.move_note(arguments.note_id, folder=arguments.folder)
return note.model_dump(mode="json")
def create_task(arguments: TaskCreateArguments, _: ToolExecutionContext) -> dict:
return task_service.create_task(**arguments.model_dump()).model_dump(mode="json")
def update_task(arguments: TaskUpdateArguments, _: ToolExecutionContext) -> dict:
values = arguments.model_dump(exclude_unset=True)
task_id = values.pop("task_id")
return task_service.update_task(task_id, values).model_dump(mode="json")
def list_tasks(arguments: TaskListArguments, _: ToolExecutionContext) -> dict:
items, total = task_service.list_tasks(**arguments.model_dump())
return {
"items": [item.model_dump(mode="json") for item in items],
"page": {"total": total, "limit": arguments.limit, "offset": arguments.offset},
}
def read_attachment(arguments: AttachmentReadArguments, _: ToolExecutionContext) -> dict:
return attachment_service.read_attachment(**arguments.model_dump())
def transcribe_audio(arguments: AudioTranscribeArguments, _: ToolExecutionContext) -> dict:
return transcription_service.create_transcription(
arguments.attachment_id, arguments.language
).model_dump(mode="json")
def _register(
registry: ToolRegistry,
*,
@@ -178,3 +250,51 @@ def register_builtin_tools(registry: ToolRegistry) -> None:
executor=list_notes,
permission="notes.read",
)
_register(
registry,
name="notes.move",
description="Move a note to another folder while preserving note_id.",
arguments_model=NoteMoveArguments,
executor=move_note,
permission="notes.write",
)
_register(
registry,
name="tasks.create",
description="Create a persistent task.",
arguments_model=TaskCreateArguments,
executor=create_task,
permission="tasks.write",
)
_register(
registry,
name="tasks.update",
description="Update a persistent task.",
arguments_model=TaskUpdateArguments,
executor=update_task,
permission="tasks.write",
)
_register(
registry,
name="tasks.list",
description="List persistent tasks.",
arguments_model=TaskListArguments,
executor=list_tasks,
permission="tasks.read",
)
_register(
registry,
name="attachments.read",
description="Read a UTF-8 attachment from host-managed attachment storage.",
arguments_model=AttachmentReadArguments,
executor=read_attachment,
permission="attachments.read",
)
_register(
registry,
name="audio.transcribe",
description="Read a host-generated transcript for an audio attachment.",
arguments_model=AudioTranscribeArguments,
executor=transcribe_audio,
permission="attachments.read",
)
+27 -1
View File
@@ -10,13 +10,39 @@ class PermissionMode(str, Enum):
deny = "deny"
KNOWN_PERMISSIONS = frozenset(
{
"notes.read",
"notes.search",
"notes.write",
"notes.delete",
"tasks.read",
"tasks.write",
"attachments.read",
"network.request",
"secrets.use",
"ui.command",
"ui.settings",
"ui.sidebar",
}
)
class PermissionPolicy:
def __init__(self) -> None:
self._rules: dict[str, PermissionMode] = {
"notes.read": PermissionMode.allow,
"notes.search": PermissionMode.allow,
"notes.delete": PermissionMode.confirm,
"notes.write": PermissionMode.confirm,
"tasks.read": PermissionMode.allow,
"tasks.write": PermissionMode.confirm,
"attachments.read": PermissionMode.allow,
"network.request": PermissionMode.confirm,
"secrets.use": PermissionMode.confirm,
"ui.command": PermissionMode.allow,
"ui.settings": PermissionMode.allow,
"ui.sidebar": PermissionMode.allow,
}
def set_rule(self, permission: str, mode: PermissionMode) -> None:
@@ -25,7 +51,7 @@ class PermissionPolicy:
def mode_for(self, permission: str | None) -> PermissionMode:
if permission is None:
return PermissionMode.allow
return self._rules.get(permission, PermissionMode.allow)
return self._rules.get(permission, PermissionMode.deny)
@dataclass(slots=True)
+34
View File
@@ -34,11 +34,18 @@ class AgentRunNotFoundError(LookupError):
pass
class AgentCapacityError(RuntimeError):
pass
TERMINAL_STATUSES = {
AgentRunStatus.completed,
AgentRunStatus.failed,
AgentRunStatus.cancelled,
}
MAX_RUN_RECORDS = 200
MAX_EVENTS_PER_RUN = 2_000
MAX_TOOL_CALLS_PER_TURN = 50
@dataclass(slots=True)
@@ -67,6 +74,7 @@ class AgentRuntime:
self._records: dict[str, RunRecord] = {}
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
self._prune_records()
provider = self.providers.get(request.provider_id)
skill_config = None
if request.skill_id:
@@ -217,6 +225,13 @@ class AgentRuntime:
return
if turn.tool_calls:
if len(turn.tool_calls) > MAX_TOOL_CALLS_PER_TURN:
self._fail(
record,
"TOO_MANY_TOOL_CALLS",
f"Provider requested more than {MAX_TOOL_CALLS_PER_TURN} tools in one turn.",
)
return
calls = [
ToolCall(
tool_call_id=item.tool_call_id,
@@ -393,6 +408,8 @@ class AgentRuntime:
timestamp=datetime.now(timezone.utc),
)
record.events.append(event)
if len(record.events) > MAX_EVENTS_PER_RUN:
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
for queue in record.subscribers:
queue.put_nowait(event)
@@ -429,3 +446,20 @@ class AgentRuntime:
return self._records[run_id]
except KeyError as exc:
raise AgentRunNotFoundError(run_id) from exc
def _prune_records(self) -> None:
overflow = len(self._records) - MAX_RUN_RECORDS + 1
if overflow <= 0:
return
terminal = sorted(
(
record
for record in self._records.values()
if record.run.status in TERMINAL_STATUSES
),
key=lambda record: record.run.updated_at,
)
for record in terminal[:overflow]:
self._records.pop(record.run.run_id, None)
if len(self._records) >= MAX_RUN_RECORDS:
raise AgentCapacityError("Too many active Agent runs.")
+4 -1
View File
@@ -4,6 +4,8 @@ from time import perf_counter
from typing import Any, Awaitable, Callable
from pydantic import BaseModel, ValidationError
from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
from app.contracts import ToolCall, ToolDefinition, ToolResult
@@ -78,8 +80,9 @@ class ToolRegistry:
)
try:
Draft202012Validator(registered.definition.parameters).validate(call.arguments)
arguments = registered.arguments_model.model_validate(call.arguments)
except ValidationError as exc:
except (ValidationError, JsonSchemaValidationError) as exc:
return ToolResult(
tool_call_id=call.tool_call_id,
name=call.name,