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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
Reference in New Issue
Block a user