diff --git a/backend/app/agent/permissions.py b/backend/app/agent/permissions.py index 55d5922..aee818f 100644 --- a/backend/app/agent/permissions.py +++ b/backend/app/agent/permissions.py @@ -1,3 +1,5 @@ +"""Agent 工具权限策略与一次性确认票据。""" + import asyncio from dataclasses import dataclass from enum import Enum @@ -51,6 +53,7 @@ class PermissionPolicy: def mode_for(self, permission: str | None) -> PermissionMode: if permission is None: return PermissionMode.allow + # 未登记权限一律拒绝,防止扩展通过拼写错误或新权限绕过策略。 return self._rules.get(permission, PermissionMode.deny) @@ -63,6 +66,8 @@ class PermissionTicket: class PermissionManager: + """管理当前进程内的确认请求与会话级授权。""" + def __init__(self, policy: PermissionPolicy) -> None: self.policy = policy self._pending: dict[tuple[str, str], PermissionTicket] = {} @@ -94,6 +99,7 @@ class PermissionManager: if ticket is None or ticket.future.done(): return False if decision == "allow_session": + # 会话授权只存在于进程内,应用重启后按默认策略重新确认。 self._session_grants.add(ticket.permission) ticket.future.set_result(decision) return True diff --git a/backend/app/agent/runtime.py b/backend/app/agent/runtime.py index bbe029c..78af004 100644 --- a/backend/app/agent/runtime.py +++ b/backend/app/agent/runtime.py @@ -1,3 +1,5 @@ +"""Agent 运行时:负责模型轮次、工具调用、权限确认与事件发布。""" + from __future__ import annotations import asyncio @@ -50,6 +52,8 @@ MAX_TOOL_CALLS_PER_TURN = 50 @dataclass(slots=True) class RunRecord: + """单次运行的可变上下文,仅由 AgentRuntime 持有。""" + run: AgentRun request: AgentRunCreateRequest skill_config: AgentConfiguration | None = None @@ -60,6 +64,8 @@ class RunRecord: class AgentRuntime: + """进程内 Agent 编排器;对外返回深拷贝,避免调用方修改运行状态。""" + def __init__( self, providers: ProviderRegistry, @@ -98,6 +104,7 @@ class AgentRuntime: ) allowed_tools = list(request.allowed_tools) if skill_config is not None: + # 同时指定 Skill 与工具白名单时取交集,避免 Skill 扩大调用权限。 allowed_tools = ( [name for name in skill_config.allowed_tools if name in allowed_tools] if allowed_tools @@ -142,6 +149,8 @@ class AgentRuntime: async def events(self, run_id: str) -> AsyncIterator[AgentEvent]: record = self._get_record(run_id) + # 先回放快照再订阅实时事件,使晚加入的 SSE 客户端也能恢复界面状态。 + # TODO(agent): 持久化事件并支持 Last-Event-ID,进程重启后仍可续传。 queue: asyncio.Queue[AgentEvent] = asyncio.Queue() record.subscribers.add(queue) history = [event.model_copy(deep=True) for event in record.events] @@ -243,6 +252,7 @@ class AgentRuntime: messages.append( Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls) ) + # 工具可以并发执行,但结果按模型原始调用顺序写回上下文,保证轮次可复现。 semaphore = asyncio.Semaphore(record.request.max_concurrent_tools) async def execute(call: ToolCall) -> ToolResult: @@ -313,6 +323,7 @@ class AgentRuntime: if mode == PermissionMode.deny: result = self._permission_denied(call) elif mode == PermissionMode.confirm and permission: + # 运行状态必须在等待期间可见,前端才能展示并处理权限确认卡片。 ticket = self.permissions.create_ticket(record.run.run_id, permission) record.run.status = AgentRunStatus.waiting_permission self._publish( @@ -408,6 +419,7 @@ 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: @@ -448,6 +460,7 @@ class AgentRuntime: raise AgentRunNotFoundError(run_id) from exc def _prune_records(self) -> None: + # 只清理终态记录,绝不为了容量取消仍在执行或等待授权的任务。 overflow = len(self._records) - MAX_RUN_RECORDS + 1 if overflow <= 0: return diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index 4dba934..a3724d6 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -1,3 +1,5 @@ +"""Agent 工具注册与执行边界。""" + import inspect from dataclasses import dataclass from time import perf_counter @@ -29,6 +31,8 @@ class ToolNotFoundError(LookupError): class ToolRegistry: + """统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。""" + def __init__(self) -> None: self._tools: dict[str, RegisteredTool] = {} @@ -80,6 +84,7 @@ class ToolRegistry: ) try: + # JSON Schema 约束模型可见的协议,Pydantic 再完成运行时类型转换。 Draft202012Validator(registered.definition.parameters).validate(call.arguments) arguments = registered.arguments_model.model_validate(call.arguments) except (ValidationError, JsonSchemaValidationError) as exc: @@ -103,7 +108,7 @@ class ToolRegistry: output=output, duration_ms=round((perf_counter() - started) * 1000), ) - except Exception as exc: # Tool failures are isolated from the Agent loop. + except Exception as exc: # 工具失败转换成结构化结果,由模型决定是否降级或重试。 return ToolResult( tool_call_id=call.tool_call_id, name=call.name, diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 28b4a18..2f701cb 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -67,6 +67,7 @@ class SkillRuntime: self._records: dict[str, _SkillRecord] = {} def install(self, package_path: str | Path) -> Skill: + # TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态。 root = _package_dir(package_path) raw = _read_yaml(root / "skill.yaml") if "id" in raw and "skill_id" not in raw: @@ -252,6 +253,7 @@ class PluginRuntime: self._records: dict[str, _PluginRecord] = {} def install(self, package_path: str | Path) -> Plugin: + # 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码。 root = _package_dir(package_path) raw = _read_yaml(root / "plugin.yaml") if "id" in raw and "plugin_id" not in raw: @@ -315,6 +317,7 @@ class PluginRuntime: if record.plugin.enabled: return record.plugin.model_copy(deep=True) if record.plugin.manifest.backend.type == "mcp": + # TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。 record.plugin.status = PluginStatus.dependency_missing raise ExtensionError( "PLUGIN_HOST_UNAVAILABLE", @@ -366,6 +369,7 @@ class PluginRuntime: ) record.registered_tools.append(spec.name) except Exception as exc: + # 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。 for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() diff --git a/backend/app/providers/credentials.py b/backend/app/providers/credentials.py index a9bc9a5..19e83c3 100644 --- a/backend/app/providers/credentials.py +++ b/backend/app/providers/credentials.py @@ -1,3 +1,5 @@ +"""Provider 凭据解析及本地加密存储。""" + import json import os import re @@ -43,6 +45,8 @@ class EnvironmentCredentialResolver: class EncryptedCredentialStore: """将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。""" + # TODO(security): 桌面 Host 接入后将主密钥迁移到系统钥匙串/凭据保险库。 + def __init__(self) -> None: self._lock = threading.RLock() @@ -75,6 +79,7 @@ class EncryptedCredentialStore: key_path.parent.mkdir(parents=True, exist_ok=True) self._restrict(key_path.parent, 0o700) if not key_path.exists(): + # 先写临时文件再原子替换,避免异常退出留下半截主密钥。 temporary = key_path.with_suffix(".tmp") temporary.write_bytes(Fernet.generate_key()) self._restrict(temporary, 0o600) @@ -112,6 +117,7 @@ class EncryptedCredentialStore: encoding="utf-8", ) self._restrict(temporary, 0o600) + # 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。 temporary.replace(store_path) self._restrict(store_path, 0o600) @@ -158,6 +164,7 @@ class ChainedCredentialResolver: self._resolvers = resolvers def resolve(self, credential_id: str | None) -> str | None: + # 顺序即优先级:调用方可让 Host 注入值覆盖本地开发凭据。 for resolver in self._resolvers: value = resolver.resolve(credential_id) if value: diff --git a/backend/app/services/transcription_service.py b/backend/app/services/transcription_service.py index 111c907..d4c6f81 100644 --- a/backend/app/services/transcription_service.py +++ b/backend/app/services/transcription_service.py @@ -1,3 +1,5 @@ +"""转写适配层;第一阶段消费文本附件或桌面 Host 预生成的旁路文本。""" + from __future__ import annotations from collections import OrderedDict @@ -13,6 +15,7 @@ MAX_JOBS = 100 def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob: + # TODO(ai-core): 第二阶段接入本地 ASR 队列后,保留相同 Job 契约替换此同步降级实现。 del language # 预生成 transcript 暂不需要语言识别。 source = attachment_path(attachment_id) transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")