diff --git a/README.md b/README.md index db987a5..bbcf174 100644 --- a/README.md +++ b/README.md @@ -1 +1,107 @@ -# FileFlash Monorepo +# FileFlash + +FileFlash 是一个面向个人与团队的现代文件工作台:上传、管理、预览、分享、回收站、后台治理与 Agent 工作流集中在一个清爽的 Web / Desktop 体验里。 + +## Highlights + +- 文件云盘:文件夹、批量操作、拖拽上传、分片上传、下载与回收站 +- 在线预览:图片、PDF、音视频、压缩包等常见文件类型 +- 安全分享:公开分享链接、访问控制、分享中心与接收列表 +- 管理后台:用户、存储、内容审核、日志、通知、系统状态与注册规则 +- Agent 工作区:任务会话、技能管理、计划执行与事件追踪 +- 自托管优先:PostgreSQL + Redis + MinIO,后端异步 API,前端支持 Web 与 Electron + +## Tech Stack + +- Frontend: Vue 3, Vite, TypeScript, Pinia, Naive UI, Vitest +- Desktop: Electron +- Backend: FastAPI, Pydantic, SQLAlchemy Async, PostgreSQL +- Infra: Redis, MinIO, Flyway, uv, Bun + +## Quick Start + +### 1. Start dependencies + +```bash +cd docker/postgresql-16 +docker compose up -d + +cd ../redis +docker compose up -d + +cd ../minio +docker compose up -d +``` + +PostgreSQL compose includes Flyway migration. MinIO console runs at `http://localhost:9001`. + +### 2. Configure backend + +```bash +cd app +cp .env.example .env +``` + +Adjust `.env` if needed. For the bundled Docker services, keep database, Redis, and MinIO values aligned with the compose files. + +### 3. Start backend + +```bash +cd app +uv run fileflash +``` + +Backend API: `http://localhost:8080` +Health check: `http://localhost:8080/health` + +Development seed accounts: + +| Role | Username | Password | +| --- | --- | --- | +| Admin | `admin` | `admin123` | +| User | `demo` | `demo123` | + +### 4. Start frontend + +```bash +cd web +bun install +bun run dev +``` + +Frontend dev server: `http://localhost:5173` + +## Common Commands + +```bash +# Backend tests +cd app && uv run pytest + +# Backend import smoke test +cd app && uv run python -c "from fileflash.main import app; print(app.title)" + +# Frontend type check +cd web && bun run check + +# Frontend build +cd web && bun run build + +# Electron dev +cd web && bun run electron:dev +``` + +## Project Layout + +```text +app/ FastAPI backend, async services, schemas, workers, tests +web/ Vue frontend, API clients, mock handlers, pages, Electron shell +docker/ PostgreSQL/Flyway, Redis, MinIO local infrastructure +docs/ Design notes, implementation plans, project memory +``` + +## Notes + +- API responses use a unified envelope: `success`, `code`, `message`, `data`, `timestamp`. +- Request and response fields use `camelCase`. +- Refresh tokens stay in HttpOnly cookies; the frontend only persists the access token. +- Production deployments must replace secrets in `app/.env`, especially `JWT_SECRET_KEY`. diff --git a/app/src/fileflash/agents/harness/permission.py b/app/src/fileflash/agents/harness/permission.py new file mode 100644 index 0000000..d5e00e0 --- /dev/null +++ b/app/src/fileflash/agents/harness/permission.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ...models import AgentUserSetting +from ...schemas.agent import AgentDataPolicy, AgentExecutionPolicy, PlanAgentRequest +from .tool_registry import REGISTRY + + +@dataclass(frozen=True, slots=True) +class EffectivePermission: + execution_policy: AgentExecutionPolicy + data_policy: AgentDataPolicy + allowed_tools: frozenset[str] + skill_key: str | None + deny_read_content: bool + high_risk_confirmed: bool + + +class PermissionResolver: + async def effective( + self, + *, + request: PlanAgentRequest, + setting: AgentUserSetting | None, + skill: Any, + high_risk_confirmed: bool, + ) -> EffectivePermission: + execution_policy = request.execution_policy + data_policy = _merge_data_policy(request.data_policy, setting) + skill_whitelist = _skill_tool_whitelist(skill) + allowed_tools = frozenset(skill_whitelist) + skill_key = _skill_key(skill) + deny_read_content = ( + not data_policy.allow_file_content + or not data_policy.allowed_mime_types + ) + return EffectivePermission( + execution_policy=execution_policy, + data_policy=data_policy, + allowed_tools=allowed_tools, + skill_key=skill_key, + deny_read_content=deny_read_content, + high_risk_confirmed=high_risk_confirmed, + ) + + +def _merge_data_policy( + request_policy: AgentDataPolicy, setting: AgentUserSetting | None +) -> AgentDataPolicy: + if setting is None: + return request_policy + setting_policy = _setting_data_policy(setting) + allow = request_policy.allow_file_content and setting_policy.allow_file_content + max_bytes = min(request_policy.max_read_bytes, setting_policy.max_read_bytes) + allowed_mimes = _intersect_mime_globs( + request_policy.allowed_mime_types, setting_policy.allowed_mime_types + ) + return AgentDataPolicy( + allow_file_content=allow, + max_read_bytes=max_bytes, + allowed_mime_types=allowed_mimes, + ) + + +def _setting_data_policy(setting: AgentUserSetting) -> AgentDataPolicy: + raw = setting.default_data_policy_json or {} + if not isinstance(raw, dict): + raw = {} + return AgentDataPolicy.model_validate(raw) + + +def _intersect_mime_globs(a: list[str], b: list[str]) -> list[str]: + # ["*/*"] means "all"; intersection with X = X. + if "*/*" in a and "*/*" in b: + return ["*/*"] + if "*/*" in a: + return list(b) + if "*/*" in b: + return list(a) + return [m for m in a if m in b] + + +def _skill_tool_whitelist(skill: Any) -> tuple[str, ...]: + if skill is None: + return REGISTRY.all_names() + raw = getattr(skill, "tool_whitelist_json", None) + if isinstance(raw, list) and raw: + tools = tuple(str(item) for item in raw if str(item).strip()) + unknown = REGISTRY.unknown_names(tools) + if unknown: + from ...core.errors import ApiError + raise ApiError( + status_code=422, + code=422, + message="Unknown agent tool in selected skill", + data={"unknownTools": sorted(unknown)}, + ) + return tools + return REGISTRY.all_names() + + +def _skill_key(skill: Any) -> str | None: + if skill is None: + return None + return str(getattr(skill, "skill_key", None) or "") + + +def _apply_setting_defaults( + request: PlanAgentRequest, setting: AgentUserSetting | None +) -> PlanAgentRequest: + if setting is None: + return request + merged_policy = _merge_data_policy(request.data_policy, setting) + budget = request.hints.budget_tokens + if budget == 8000 and setting.default_budget_tokens: + budget = int(setting.default_budget_tokens) + max_steps = request.hints.max_steps + if max_steps == 12 and setting.default_max_steps: + max_steps = int(setting.default_max_steps) + return request.model_copy( + update={ + "data_policy": merged_policy, + "hints": request.hints.model_copy( + update={"budget_tokens": budget, "max_steps": max_steps} + ), + } + ) + + +__all__ = ["EffectivePermission", "PermissionResolver", "_apply_setting_defaults"] diff --git a/app/src/fileflash/agents/harness/policy.py b/app/src/fileflash/agents/harness/policy.py index 730322b..8e08f71 100644 --- a/app/src/fileflash/agents/harness/policy.py +++ b/app/src/fileflash/agents/harness/policy.py @@ -1,9 +1,20 @@ from __future__ import annotations +import fnmatch from dataclasses import dataclass, field +from typing import Any, Literal +from sqlalchemy import and_, select + +from ...core.mime import resolve_file_mime_type +from ...models import File +from ...models.enums import FileStatus from ...schemas.agent import AgentProposedAction -from .tool_registry import REGISTRY +from .permission import EffectivePermission +from .tool_registry import REGISTRY, ToolContext + +_CONTENT_READ_TOOLS = frozenset({"drive.readFile"}) +_Phase = Literal["planning", "executing"] @dataclass(slots=True) @@ -45,22 +56,117 @@ def normalize_action_risk(action: AgentProposedAction) -> AgentProposedAction: class PolicyGuard: - async def evaluate_tool_call( + async def evaluate( self, *, - tool_name: str, - high_risk_confirmed: bool = False, + ctx: ToolContext, + action: AgentProposedAction, + permission: EffectivePermission, + phase: _Phase, ) -> PolicyDecision: try: - REGISTRY.get(tool_name) + spec = REGISTRY.get(action.tool) except KeyError: return PolicyDecision( allowed=False, - reasons=[f"Unsupported agent tool: {tool_name}"], + reasons=[f"Unsupported agent tool: {action.tool}"], + ) + if action.tool not in permission.allowed_tools: + return PolicyDecision( + allowed=False, + reasons=[f"Tool not permitted by active skill/policy: {action.tool}"], + ) + if spec.side_effect == "read" and action.tool in _CONTENT_READ_TOOLS: + decision = await self._check_content_read( + ctx=ctx, action=action, permission=permission + ) + if decision is not None: + return decision + if spec.risk_level == "high" and not permission.high_risk_confirmed: + return PolicyDecision( + allowed=False, + reasons=["High-risk action requires explicit confirmation."], ) - if classify_tool_risk(tool_name) == "high" and not high_risk_confirmed: + if permission.execution_policy == "planOnly" and phase == "executing": return PolicyDecision( allowed=False, - reasons=["High-risk delete action requires explicit user confirmation."], + reasons=["planOnly policy forbids execution."], ) return PolicyDecision(allowed=True) + + async def _check_content_read( + self, + *, + ctx: ToolContext, + action: AgentProposedAction, + permission: EffectivePermission, + ) -> PolicyDecision | None: + if permission.deny_read_content: + return PolicyDecision( + allowed=False, + reasons=["File content access disabled by dataPolicy."], + ) + bytes_requested = self._bytes_requested(action.input) + if bytes_requested > permission.data_policy.max_read_bytes: + return PolicyDecision( + allowed=False, + reasons=[ + f"Requested bytes ({bytes_requested}) exceed max_read_bytes " + f"({permission.data_policy.max_read_bytes})." + ], + ) + mime = await _resolve_target_mime(ctx=ctx, action=action) + if mime is not None and not _mime_allowed( + mime, permission.data_policy.allowed_mime_types + ): + return PolicyDecision( + allowed=False, + reasons=[f"File mime '{mime}' not in allowed_mime_types."], + ) + return None + + def _bytes_requested(self, action_input: dict[str, Any]) -> int: + max_bytes = int(action_input.get("maxBytes", 262144) or 262144) + return max_bytes + + +def _mime_allowed(mime: str, allowed: list[str]) -> bool: + lowered = mime.lower() + return any(fnmatch.fnmatch(lowered, pattern.lower()) for pattern in allowed) + + +async def _resolve_target_mime( + *, ctx: ToolContext, action: AgentProposedAction +) -> str | None: + file_id = action.input.get("fileId") or action.input.get("id") + if file_id is None: + return None + try: + parsed = int(str(file_id)) + except (TypeError, ValueError): + return None + row = await ctx.db.scalar( + select(File).where( + and_( + File.file_id == parsed, + File.owner_id == ctx.user_id, + File.status == FileStatus.ACTIVE, + ) + ) + ) + if row is None: + return None + return resolve_file_mime_type( + mime_type=row.mime_type, + file_ext=row.file_ext, + file_name=row.file_name, + ) + + +__all__ = [ + "PolicyDecision", + "PolicyGuard", + "classify_tool_risk", + "classify_tool_side_effect", + "normalize_action_risk", +] diff --git a/app/src/fileflash/agents/harness/router.py b/app/src/fileflash/agents/harness/router.py index 2b3b639..b91a9be 100644 --- a/app/src/fileflash/agents/harness/router.py +++ b/app/src/fileflash/agents/harness/router.py @@ -6,8 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...core.errors import ApiError +from ...core.settings import Settings, get_settings from ...services.file import FileService from ...services.folder import FolderService +from ...s3.minio_client import MinioObjectStorageClient from .tool_registry import REGISTRY, ToolContext @@ -18,11 +20,28 @@ class ToolCall: class ToolRouter: - def __init__(self, *, db: AsyncSession, user_id: int) -> None: + def __init__( + self, + *, + db: AsyncSession, + user_id: int, + settings: Settings | None = None, + storage_reader: MinioObjectStorageClient | None = None, + ) -> None: self.db = db self.user_id = user_id + self.settings = settings or get_settings() self.file_service = FileService(db=db) self.folder_service = FolderService(db=db) + self._storage_reader = storage_reader + + def _resolve_storage_reader(self) -> MinioObjectStorageClient | None: + if self._storage_reader is not None: + return self._storage_reader + try: + return MinioObjectStorageClient.from_settings(self.settings) + except Exception: + return None async def dispatch(self, call: ToolCall) -> dict[str, Any]: tool_name = str(call.tool_name or "").strip() @@ -40,6 +59,7 @@ async def dispatch(self, call: ToolCall) -> dict[str, Any]: user_id=self.user_id, file_service=self.file_service, folder_service=self.folder_service, + storage_reader=self._resolve_storage_reader(), ) return await spec.handler(ctx, dict(call.arguments or {})) diff --git a/app/src/fileflash/agents/harness/skill_tool.py b/app/src/fileflash/agents/harness/skill_tool.py new file mode 100644 index 0000000..e4dce2b --- /dev/null +++ b/app/src/fileflash/agents/harness/skill_tool.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Any + +from ...models import AgentUserSetting +from ...repositories import AgentSkillRepository +from ...schemas.agent import PlanAgentRequest +from .permission import EffectivePermission, PermissionResolver +from .tool_registry import REGISTRY, ToolContext, ToolSpec + + +async def _use_skill_handler(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + # This handler is never reached at runtime — the planner intercepts agent.useSkill + # before dispatch. It exists only so the tool has a valid handler for registration. + return {"bound": False, "message": "agent.useSkill must be intercepted by the planner."} + + +def register_use_skill_tool() -> None: + try: + REGISTRY.get("agent.useSkill") + return # already registered + except KeyError: + pass + REGISTRY.register( + ToolSpec( + name="agent.useSkill", + description=( + "Adopt a skill to constrain your tool set to that skill's whitelist. " + "Call once during planning if a skill fits; optional. Returns the bound " + "tool list. Use skillKey 'none' to decline all skills. " + "Cannot be used during execution." + ), + input_schema={ + "type": "object", + "properties": { + "skillKey": { + "type": "string", + "description": "One of the offered skill keys, or 'none' to decline.", + } + }, + "required": ["skillKey"], + }, + side_effect="read", + risk_level="low", + requires_confirmation=False, + handler=_use_skill_handler, + ) + ) + + +async def bind_skill_in_planner( + *, + db: Any, + user_id: int, + skill_key: str, + candidates: list[Any], + request: PlanAgentRequest, + setting: AgentUserSetting | None, + current_permission: EffectivePermission, +) -> tuple[EffectivePermission, dict[str, Any]]: + if skill_key == "none": + return current_permission, {"bound": False, "declined": True, "skillKey": "none"} + candidate_keys = {getattr(c, "skill_key", None) for c in candidates} + if skill_key not in candidate_keys: + return current_permission, { + "bound": False, + "skillKey": skill_key, + "message": f"Unknown or unoffered skill key: {skill_key}", + } + repo = AgentSkillRepository(db) + skill = await repo.get_by_key(skill_key=skill_key, user_id=user_id) + if skill is None: + return current_permission, { + "bound": False, + "skillKey": skill_key, + "message": f"Skill not found: {skill_key}", + } + new_perm = await PermissionResolver().effective( + request=request, + setting=setting, + skill=skill, + high_risk_confirmed=current_permission.high_risk_confirmed, + ) + return new_perm, { + "bound": True, + "skillKey": skill_key, + "allowedTools": sorted(new_perm.allowed_tools), + } + + +# Register on import so the LLM sees the tool whenever builtin tools are registered. +register_use_skill_tool() + + +__all__ = ["bind_skill_in_planner", "register_use_skill_tool"] diff --git a/app/src/fileflash/agents/harness/tool_registry.py b/app/src/fileflash/agents/harness/tool_registry.py index c508a86..096df1f 100644 --- a/app/src/fileflash/agents/harness/tool_registry.py +++ b/app/src/fileflash/agents/harness/tool_registry.py @@ -17,6 +17,7 @@ class ToolContext: user_id: int file_service: Any folder_service: Any + storage_reader: Any = None ToolHandler = Callable[[ToolContext, dict[str, Any]], Awaitable[dict[str, Any]]] diff --git a/app/src/fileflash/agents/runtime/execute_runner.py b/app/src/fileflash/agents/runtime/execute_runner.py index 04929c9..630a64a 100644 --- a/app/src/fileflash/agents/runtime/execute_runner.py +++ b/app/src/fileflash/agents/runtime/execute_runner.py @@ -17,13 +17,26 @@ AgentActionLogRepository, AgentInboxMessageRepository, AgentPlanRepository, + AgentSettingsRepository, + AgentSkillRepository, AgentWorkSessionRepository, ) -from ...schemas.agent import AgentExecutionResult, AgentProposedAction, ExecuteAgentRequest +from ...schemas.agent import ( + AgentDataPolicy, + AgentExecutionResult, + AgentProposedAction, + ExecuteAgentRequest, + PlanAgentRequest, +) from ..harness.ask import AskProtocol from ..harness.event_bus import AgentEventBus, AgentEventEnvelope +from ..harness.permission import ( + PermissionResolver, + _apply_setting_defaults, +) from ..harness.policy import PolicyGuard from ..harness.router import ToolCall, ToolRouter +from ..harness.tool_registry import ToolContext from .llm import AnswerClient, AnthropicPlannerClient from .reference_rules import is_symbolic_id_placeholder, parse_step_reference @@ -85,6 +98,32 @@ async def _run( for item in (plan.proposed_actions_json or []) ] high_risk_confirmed = bool(request.approval.high_risk_confirmed) + # Build the effective permission from the plan row (execution_policy + + # data_policy_json) merged with the user's setting defaults (取最严). + setting = await AgentSettingsRepository(db).get_by_user_id(int(job.requested_by)) + base_request = PlanAgentRequest.model_validate( + { + "chatSessionId": request.chat_session_id, + "input": str(getattr(plan, "input_text", "") or "") or "-", + "context": {"rootFolderId": "root"}, + "executionPolicy": getattr(plan, "execution_policy", "confirm") or "confirm", + "dataPolicy": AgentDataPolicy.model_validate( + getattr(plan, "data_policy_json", None) or {} + ).model_dump(by_alias=True, mode="json"), + } + ) + base_request = _apply_setting_defaults(base_request, setting) + skill = None + if getattr(plan, "chosen_skill_id", None): + skill = await AgentSkillRepository(db).get_by_key( + skill_key=str(plan.chosen_skill_id), user_id=int(job.requested_by) + ) + permission = await PermissionResolver().effective( + request=base_request, + setting=setting, + skill=skill, + high_risk_confirmed=high_risk_confirmed, + ) router = ToolRouter(db=db, user_id=int(job.requested_by)) action_logs = AgentActionLogRepository(db) step_outputs: dict[int, dict[str, Any]] = {} @@ -115,17 +154,47 @@ async def _run( if skip_current: continue - decision = await self.policy_guard.evaluate_tool_call( - tool_name=action.tool, - high_risk_confirmed=high_risk_confirmed, + decision = await self.policy_guard.evaluate( + ctx=ToolContext( + db=db, + user_id=int(job.requested_by), + file_service=None, + folder_service=None, + ), + action=action, + permission=permission, + phase="executing", ) if not decision.allowed: - raise ApiError( - status_code=409, - code=409, - message="High-risk action requires confirmation", - data={"reasons": decision.reasons, "step": action.step, "tool": action.tool}, + denied_started = datetime.now(UTC) + await action_logs.append_step( + job_id=int(job.job_id), + step_no=action.step, + tool_name=action.tool, + inputs_json=action.input, + status="denied", + started_at=denied_started, + ) + await action_logs.finish_step( + job_id=int(job.job_id), + step_no=action.step, + outputs_json={}, + status="denied", + duration_ms=0, + error_message="; ".join(decision.reasons)[:2000], ) + await db.commit() + await self._publish_tool( + "tool.failed", + job_id=int(job.job_id), + step=action.step, + tool=action.tool, + payload={"denied": True, "reasons": decision.reasons}, + ) + warnings.append( + f"Step {action.step} denied by policy: {'; '.join(decision.reasons)}" + ) + continue started = datetime.now(UTC) try: @@ -272,9 +341,22 @@ async def _handle_step_boundary_controls( await inbox_repo.mark_dropped(inbox_message_id=int(ctrl.inbox_message_id)) await self._publish_state("agent.resumed", job_id=int(job.job_id)) elif kind == AgentInboxKind.CONTROL_SKIP: + if _control_step(ctrl) != action.step: + continue await inbox_repo.mark_dropped(inbox_message_id=int(ctrl.inbox_message_id)) warnings.append(f"Step {action.step} skipped by user") skip_current = True + elif kind == AgentInboxKind.CONTROL_DENY: + if _control_step(ctrl) != action.step: + continue + await inbox_repo.mark_dropped(inbox_message_id=int(ctrl.inbox_message_id)) + reason = _control_reason(ctrl) or "denied by user" + warnings.append(f"Step {action.step} denied by user: {reason}") + skip_current = True + elif kind == AgentInboxKind.CONTROL_APPROVE: + if _control_step(ctrl) != action.step: + continue + await inbox_repo.mark_dropped(inbox_message_id=int(ctrl.inbox_message_id)) else: await inbox_repo.mark_dropped(inbox_message_id=int(ctrl.inbox_message_id)) await db.commit() @@ -344,6 +426,29 @@ def _parse_job_id(raw: str) -> int: return value +def _control_metadata(ctrl: Any) -> dict[str, Any]: + payload = getattr(ctrl, "payload_json", None) + if not isinstance(payload, dict): + return {} + metadata = payload.get("metadata") + return metadata if isinstance(metadata, dict) else {} + + +def _control_step(ctrl: Any) -> int | None: + try: + value = int(_control_metadata(ctrl).get("step")) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + +def _control_reason(ctrl: Any) -> str | None: + value = _control_metadata(ctrl).get("reason") + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + def _resolve_references( value: Any, step_outputs: dict[int, dict[str, Any]], diff --git a/app/src/fileflash/agents/runtime/plan_runner.py b/app/src/fileflash/agents/runtime/plan_runner.py index 33e8483..a947b38 100644 --- a/app/src/fileflash/agents/runtime/plan_runner.py +++ b/app/src/fileflash/agents/runtime/plan_runner.py @@ -15,7 +15,6 @@ from ...models import AgentPlan, AgentSkill, BackgroundJob, File, Folder from ...models.enums import AgentExecutionPolicy as DbAgentExecutionPolicy from ...models.enums import FileStatus, FolderStatus, FolderType -from ...repositories import AgentSkillRepository from ...repositories.agent.contracts import AgentSkillCatalogEntry from ...schemas.agent import ( AgentChosenSkill, @@ -25,13 +24,20 @@ AgentProposedAction, PlanAgentRequest, ) -from ..harness.ask import AskProtocol +from ..harness.ask import AskProtocol, AskTimedOut from ..harness.event_bus import AgentEventBus -from ..harness.policy import classify_tool_side_effect, normalize_action_risk +from ..harness.permission import ( + EffectivePermission, + PermissionResolver, + _apply_setting_defaults, +) +from ..harness.policy import PolicyGuard, classify_tool_side_effect, normalize_action_risk from ..harness.router import ToolCall, ToolRouter -from ..harness.tool_registry import REGISTRY +from ..harness.skill_tool import bind_skill_in_planner # noqa: F401 (registers agent.useSkill) +from ..harness.tool_registry import REGISTRY, ToolContext from .llm import AnthropicPlannerClient, PlannerClient from .reference_rules import is_symbolic_id_placeholder, parse_step_reference +from ...repositories import AgentSettingsRepository, AgentSkillRepository class PlanRunner: @@ -69,26 +75,46 @@ async def _run( request = PlanAgentRequest.model_validate(dict(job.payload or {})) user_id = int(job.requested_by) - skill = await _choose_skill( + setting_repo = AgentSettingsRepository(db) + setting = await setting_repo.get_by_user_id(user_id) + request = _apply_setting_defaults(request, setting) + candidates = await _candidate_skills( db, user_id=user_id, task_input=request.input, prefer_skill_id=request.hints.prefer_skill_id, + k=self.settings.agent_skill_candidate_k, ) metadata = await _collect_context_metadata(db, user_id=user_id, request=request) - allowed_tools = _skill_tool_whitelist(skill) + # If a preferred skill is forced via hint, bind it now; otherwise start + # unbound (full registry, read-only exploration) and let the LLM bind via + # agent.useSkill during planning. + forced_skill = candidates[0] if (request.hints.prefer_skill_id and candidates) else None + permission = await PermissionResolver().effective( + request=request, + setting=setting, + skill=forced_skill, + high_risk_confirmed=False, + ) + skill = forced_skill + allowed_tools = tuple(sorted(permission.allowed_tools)) allowed_tool_set = set(allowed_tools) exploration_tools = tuple( - tool_name for tool_name in allowed_tools if REGISTRY.get(tool_name).side_effect == "read" + tool_name + for tool_name in allowed_tools + if REGISTRY.get(tool_name).side_effect == "read" ) exploration_tool_set = set(exploration_tools) + # Include the useSkill meta-tool for the LLM during planning: + planning_exploration_tools = exploration_tools + ("agent.useSkill",) planner_router = ToolRouter(db=db, user_id=user_id) + policy_guard = PolicyGuard() tool_call_budget = min(self.settings.agent_job_max_tool_calls, 32) planned_tool_calls = 0 planning_evidence: list[AgentPlanningEvidence] = [] async def _planning_tool_executor(tool_name: str, args: dict[str, Any]) -> dict[str, Any]: - nonlocal planned_tool_calls + nonlocal planned_tool_calls, permission, skill, allowed_tools, allowed_tool_set, exploration_tools, planning_exploration_tools planned_tool_calls += 1 if planned_tool_calls > tool_call_budget: raise ApiError( @@ -96,10 +122,53 @@ async def _planning_tool_executor(tool_name: str, args: dict[str, Any]) -> dict[ code=400, message="Planner exceeded exploratory tool-call budget", ) - if tool_name not in allowed_tool_set: + # Intercept the useSkill meta-tool — never dispatch it. + if tool_name == "agent.useSkill": + new_perm, payload = await bind_skill_in_planner( + db=db, + user_id=user_id, + skill_key=str(args.get("skillKey", "")), + candidates=candidates, + request=request, + setting=setting, + current_permission=permission, + ) + if payload.get("bound"): + permission = new_perm + skill = next( + (c for c in candidates if getattr(c, "skill_key", None) == payload["skillKey"]), + skill, + ) + allowed_tools = tuple(sorted(permission.allowed_tools)) + allowed_tool_set = set(allowed_tools) + exploration_tools = tuple( + tool_name + for tool_name in allowed_tools + if REGISTRY.get(tool_name).side_effect == "read" + ) + planning_exploration_tools = exploration_tools + ("agent.useSkill",) + if len(planning_evidence) < 12: + planning_evidence.append( + AgentPlanningEvidence( + step=planned_tool_calls, + tool=tool_name, + input=_evidence_mapping(args), + output_preview=_evidence_preview(payload), + ) + ) + return payload + # All other tools: gate via PolicyGuard (phase=planning denies content-read; writes blocked below). + decision = await policy_guard.evaluate( + ctx=ToolContext(db=db, user_id=user_id, file_service=None, folder_service=None), + action=AgentProposedAction( + step=planned_tool_calls, tool=tool_name, input=args, side_effect=classify_tool_side_effect(tool_name) + ), + permission=permission, + phase="planning", + ) + if not decision.allowed: blocked = _blocked_planning_tool_result( - tool_name=tool_name, - reason="Planner attempted a tool that is not allowed by the selected skill.", + tool_name=tool_name, reason="; ".join(decision.reasons) ) if len(planning_evidence) < 12: planning_evidence.append( @@ -111,6 +180,7 @@ async def _planning_tool_executor(tool_name: str, args: dict[str, Any]) -> dict[ ) ) return blocked + # Deny write tools during planning exploration — they belong in proposedActions. if tool_name not in exploration_tool_set: blocked = _blocked_planning_tool_result( tool_name=tool_name, @@ -141,21 +211,71 @@ async def _planning_tool_executor(tool_name: str, args: dict[str, Any]) -> dict[ ) return output - llm_payload = await self.planner_client.create_plan( - system_prompt=_system_prompt(), - user_prompt=_user_prompt( - request=request, - skill=skill, - allowed_tools=allowed_tools, - exploration_tools=exploration_tools, - metadata=metadata, - ), - max_tokens=request.hints.budget_tokens, - reasoning_effort=request.hints.reasoning_effort, - tools=REGISTRY.anthropic_tools_for(exploration_tools), - tool_executor=_planning_tool_executor, - max_tool_roundtrips=6, - ) + async def _create_plan(metadata_payload: dict[str, Any]) -> dict[str, Any]: + return await self.planner_client.create_plan( + system_prompt=_system_prompt(candidates=candidates), + user_prompt=_user_prompt( + request=request, + skill=skill, + allowed_tools=allowed_tools, + exploration_tools=exploration_tools, + metadata=metadata_payload, + ), + max_tokens=request.hints.budget_tokens, + reasoning_effort=request.hints.reasoning_effort, + tools=REGISTRY.anthropic_tools_for(planning_exploration_tools), + tool_executor=_planning_tool_executor, + max_tool_roundtrips=6, + ) + + async def _ask_and_replan( + *, + prompt: str, + schema: dict[str, Any], + reason: str, + ) -> dict[str, Any] | None: + answer = await self._ask(ask=ask, prompt=prompt, schema=schema) + if answer is None: + return None + clarified_metadata = dict(metadata) + clarified_metadata["clarification"] = { + "prompt": prompt, + "answer": answer, + "reason": reason, + } + return await _create_plan(clarified_metadata) + + asked_for_clarification = False + try: + llm_payload = await _create_plan(metadata) + except ApiError as exc: + if not _is_planning_clarification_error(exc): + raise + replanned_payload = await _ask_and_replan( + prompt=_normalization_clarification_prompt(exc), + schema={"type": "object", "properties": {"clarification": {"type": "string"}}}, + reason=exc.message, + ) + if replanned_payload is None: + raise + asked_for_clarification = True + llm_payload = replanned_payload + clarification = _clarification_request(llm_payload) + if clarification is not None: + replanned_payload = await _ask_and_replan( + prompt=clarification["prompt"], + schema=clarification["schema"], + reason="planner_requested_clarification", + ) + if replanned_payload is None: + raise ApiError( + status_code=409, + code=409, + message="Agent needs clarification before planning", + data=clarification, + ) + asked_for_clarification = True + llm_payload = replanned_payload effective_max_steps: int | None if self.settings.is_development_env: @@ -166,11 +286,28 @@ async def _planning_tool_executor(tool_name: str, args: dict[str, Any]) -> dict[ self.settings.agent_job_max_tool_calls, ) - actions = _normalize_actions( - llm_payload=llm_payload, - allowed_tools=allowed_tools, - max_steps=effective_max_steps, - ) + try: + actions = _normalize_actions( + llm_payload=llm_payload, + allowed_tools=allowed_tools, + max_steps=effective_max_steps, + ) + except ApiError as exc: + if asked_for_clarification or not _is_planning_clarification_error(exc): + raise + replanned_payload = await _ask_and_replan( + prompt=_normalization_clarification_prompt(exc), + schema={"type": "object", "properties": {"clarification": {"type": "string"}}}, + reason=exc.message, + ) + if replanned_payload is None: + raise + llm_payload = replanned_payload + actions = _normalize_actions( + llm_payload=llm_payload, + allowed_tools=allowed_tools, + max_steps=effective_max_steps, + ) chosen_skill = _chosen_skill(skill) llm_summary = str( llm_payload.get("summary") or f"Prepared {len(actions)} file action(s)." @@ -228,33 +365,40 @@ async def _ask( ) -> Any | None: if ask is None: return None - return await ask.ask( - prompt=prompt, - schema=schema, - timeout_sec=float(self.settings.agent_inbox_ask_timeout_sec), - ) + try: + return await ask.ask( + prompt=prompt, + schema=schema, + timeout_sec=float(self.settings.agent_inbox_ask_timeout_sec), + ) + except AskTimedOut as exc: + raise ApiError( + status_code=408, + code=408, + message="Agent clarification timed out", + data={"askId": str(exc.ask_id)}, + ) from exc -async def _choose_skill( +async def _candidate_skills( db: AsyncSession, *, user_id: int, task_input: str, prefer_skill_id: str | None, -) -> AgentSkill | AgentSkillCatalogEntry | None: + k: int = 3, +) -> list[Any]: repo = AgentSkillRepository(db) if prefer_skill_id: skill = await repo.get_by_key(skill_key=prefer_skill_id, user_id=user_id) if skill is None: raise ApiError(status_code=404, code=404, message="Preferred skill not found") - return skill - + return [skill] candidates = await repo.list_visible(user_id=user_id, limit=50) if not candidates: - return None - + return [] normalized_input = task_input.lower() - best: tuple[int, AgentSkillCatalogEntry] | None = None + scored: list[tuple[int, Any]] = [] for candidate in candidates: haystack = ( f"{candidate.skill_key} {candidate.name} {candidate.description} " @@ -266,18 +410,59 @@ async def _choose_skill( score += 2 if token in {"organize", "整理", "classify", "分类"} else 1 if "整理" in normalized_input and "organize" in haystack: score += 4 - if best is None or score > best[0]: - best = (score, candidate) - - if best is not None and best[0] > 0: - return best[1] - return candidates[0] + scored.append((score, candidate)) + scored.sort(key=lambda pair: pair[0], reverse=True) + positive = [pair for pair in scored if pair[0] > 0] + if not positive: + return [] # no skill forced; LLM may still pick via useSkill + return [pair[1] for pair in positive[: max(1, k)]] def _tokens(text: str) -> list[str]: return [token.strip(" ,.;:!?,。;:!?") for token in text.split() if token.strip()] +def _clarification_request(payload: dict[str, Any]) -> dict[str, Any] | None: + raw = payload.get("clarificationRequest") or payload.get("clarification_request") + if raw is None: + return None + if isinstance(raw, str): + prompt = raw.strip() + schema: dict[str, Any] = {} + elif isinstance(raw, dict): + prompt = str(raw.get("prompt") or raw.get("question") or "").strip() + raw_schema = raw.get("schema") + schema = raw_schema if isinstance(raw_schema, dict) else {} + else: + return None + if not prompt: + return None + return {"prompt": prompt, "schema": schema} + + +def _is_planning_clarification_error(error: ApiError) -> bool: + if error.status_code not in {400, 502}: + return False + message = error.message + return ( + message == "Agent plan JSON missing proposedActions" + or message == "Agent LLM returned an invalid response" + or message == "Agent LLM returned an empty response" + or message == "Agent LLM did not return valid JSON" + or message == "Agent LLM JSON must be an object" + or message.startswith("Agent action") + or message.startswith("Invalid plan action") + or "unresolved placeholder" in message + ) + + +def _normalization_clarification_prompt(error: ApiError) -> str: + return ( + "I could not turn the draft plan into executable steps. " + f"Please clarify the missing target folder, file, or constraint. Issue: {error.message}" + ) + + def _skill_key(skill: AgentSkill | AgentSkillCatalogEntry | None) -> str | None: if skill is None: return None @@ -290,26 +475,6 @@ def _skill_name(skill: AgentSkill | AgentSkillCatalogEntry | None) -> str | None return str(skill.name) -def _skill_tool_whitelist(skill: AgentSkill | AgentSkillCatalogEntry | None) -> tuple[str, ...]: - raw: Any = None - if isinstance(skill, AgentSkill): - raw = skill.tool_whitelist_json - elif skill is not None: - raw = skill.tool_whitelist_json - if isinstance(raw, list) and raw: - tools = tuple(str(item) for item in raw if str(item).strip()) - unknown = REGISTRY.unknown_names(tools) - if unknown: - raise ApiError( - status_code=422, - code=422, - message="Unknown agent tool in selected skill", - data={"unknownTools": sorted(unknown)}, - ) - return tools - return REGISTRY.all_names() - - def _chosen_skill(skill: AgentSkill | AgentSkillCatalogEntry | None) -> AgentChosenSkill | None: key = _skill_key(skill) name = _skill_name(skill) @@ -477,16 +642,39 @@ def _folder_metadata(row: Folder) -> dict[str, Any]: } -def _system_prompt() -> str: +def _system_prompt(*, candidates: list[Any] | None = None) -> str: + menu = _skill_menu(candidates or []) return ( "You are FileFlash Agent Planner. Build plans from tool-grounded facts, not assumptions. " "If you need facts, first call read-only tools; then output one final JSON object that matches outputSchema. " - "Do not read or infer file contents. Deletions are high risk and must be explicit. " + "Do not read or infer file contents unless you call drive.readFile and dataPolicy allows it. " + "Deletions are high risk and must be explicit. " "Cross-step dependencies must use '$stepN.field' references only and never symbolic placeholders " - "like 'newFolderId'." + "like 'newFolderId'. " + + menu ) +def _skill_menu(candidates: list[Any]) -> str: + if not candidates: + return "" + lines = [ + "You may use one of these skills if it fits the task. Each skill restricts which tools you may use. " + "To adopt a skill, call agent.useSkill with its key. You may also proceed without a skill " + "(free planning), but then only read-only exploration tools are available during planning.", + "", + "Available skills:", + ] + for c in candidates: + key = getattr(c, "skill_key", "?") + name = getattr(c, "name", key) + desc = getattr(c, "description", "") or "" + wl = getattr(c, "tool_whitelist_json", None) or [] + wl_str = ", ".join(str(t) for t in wl) if wl else "(all tools)" + lines.append(f"- {key} ({name}): {desc}. tools: {wl_str}") + return "\n".join(lines) + "\n" + + def _user_prompt( *, request: PlanAgentRequest, diff --git a/app/src/fileflash/agents/tools/drive.py b/app/src/fileflash/agents/tools/drive.py index f74d369..1b6eb8d 100644 --- a/app/src/fileflash/agents/tools/drive.py +++ b/app/src/fileflash/agents/tools/drive.py @@ -332,6 +332,96 @@ async def _find_duplicates(ctx: ToolContext, args: dict[str, Any]) -> dict[str, } +_TEXT_MIME_ALLOWLIST = ( + "text/", + "application/json", + "application/xml", + "application/x-yaml", + "application/javascript", + "application/x-sh", + "application/pdf", +) + + +async def _read_file(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + file_id = _parse_positive_int(_required_text(args, "fileId", "id"), "fileId") + max_bytes = _int_arg(args.get("maxBytes"), default=262144, minimum=1, maximum=1_048_576) + offset = _int_arg(args.get("offset"), default=0, minimum=0) + + row = await ctx.db.scalar( + select(File).where( + and_( + File.file_id == file_id, + File.owner_id == ctx.user_id, + File.status == FileStatus.ACTIVE, + File.is_latest.is_(True), + ) + ) + ) + if row is None: + raise ApiError(status_code=404, code=404, message="File not found") + + storage = await ctx.db.scalar( + select(StorageObject).where(StorageObject.object_id == row.storage_object_id) + ) + if storage is None or ctx.storage_reader is None: + raise ApiError(status_code=503, code=503, message="Object storage unavailable") + + mime = _resolved_mime(row) + object_key = str(storage.object_key) + stat = await ctx.storage_reader.stat_object(object_key=object_key) + size = int(stat.size) + + if not mime.lower().startswith(_TEXT_MIME_ALLOWLIST): + return { + "fileId": str(file_id), + "name": str(row.file_name), + "mime": mime, + "size": size, + "truncated": True, + "bytesReturned": 0, + "note": "Binary content not sent to model.", + } + + # Empty file or offset beyond end of file: return clean empty content. + if size == 0 or offset >= size: + return { + "fileId": str(file_id), + "name": str(row.file_name), + "mime": mime, + "size": size, + "content": "", + "truncated": False, + "bytesReturned": 0, + "offset": offset, + } + + end = min(offset + max_bytes - 1, size - 1) if size > 0 else 0 + chunks: list[bytes] = [] + received = 0 + async for chunk in ctx.storage_reader.iter_object_range( + object_key=object_key, start=offset, end=end + ): + chunks.append(chunk) + received += len(chunk) + content_bytes = b"".join(chunks) + try: + content = content_bytes.decode("utf-8", errors="replace") + except Exception: + content = content_bytes.decode("latin-1", errors="replace") + + return { + "fileId": str(file_id), + "name": str(row.file_name), + "mime": mime, + "size": size, + "content": content, + "truncated": (offset + received) < size, + "bytesReturned": received, + "offset": offset, + } + + def _active_files_query(ctx: ToolContext, *, folder_ids: list[int] | None): statement = select(File).where( and_( @@ -875,4 +965,31 @@ def _schema(properties: dict[str, Any], required: list[str] | None = None) -> di ) ) +REGISTRY.register( + ToolSpec( + name="drive.readFile", + description=( + "Read text content of a file the user owns. Returns up to maxBytes; " + "binary files are not returned directly. Subject to dataPolicy." + ), + input_schema=_schema( + { + "fileId": _FILE_ID, + "maxBytes": { + "type": "integer", + "minimum": 1, + "maximum": 1048576, + "default": 262144, + }, + "offset": {"type": "integer", "minimum": 0, "default": 0}, + }, + required=["fileId"], + ), + side_effect="read", + risk_level="medium", + requires_confirmation=False, + handler=_read_file, + ) +) + __all__ = [] diff --git a/app/src/fileflash/agents/worker.py b/app/src/fileflash/agents/worker.py index a122e31..d2430fd 100644 --- a/app/src/fileflash/agents/worker.py +++ b/app/src/fileflash/agents/worker.py @@ -37,6 +37,8 @@ def __init__( self._event_bus = event_bus or build_agent_event_bus(settings=self._settings) async def run(self) -> None: + if not self._settings.redis_url: + raise RuntimeError("REDIS_URL is required for agent worker event streaming") logger.info( "Agent worker started queue=%s group=%s concurrency=%s", self._settings.agent_queue_stream, diff --git a/app/src/fileflash/core/deps.py b/app/src/fileflash/core/deps.py index 5ac1f45..00ecfd8 100644 --- a/app/src/fileflash/core/deps.py +++ b/app/src/fileflash/core/deps.py @@ -11,6 +11,7 @@ from ..models.tables_identity import User from ..repositories import ( AgentActionLogRepository, + AgentChatSessionRepository, AgentMcpRepository, AgentMemoryRepository, AgentPlanRepository, @@ -271,6 +272,10 @@ def get_agent_action_log_repository(db: AsyncSession = Depends(get_db)) -> Agent return AgentActionLogRepository(db) +def get_agent_chat_session_repository(db: AsyncSession = Depends(get_db)) -> AgentChatSessionRepository: + return AgentChatSessionRepository(db) + + def get_agent_work_session_repository(db: AsyncSession = Depends(get_db)) -> AgentWorkSessionRepository: return AgentWorkSessionRepository(db) @@ -282,6 +287,7 @@ def get_agent_plan_service( plans: AgentPlanRepository = Depends(get_agent_plan_repository), settings_repo: AgentSettingsRepository = Depends(get_agent_settings_repository), work_sessions: AgentWorkSessionRepository = Depends(get_agent_work_session_repository), + chat_sessions: AgentChatSessionRepository = Depends(get_agent_chat_session_repository), ) -> PlanService: return PlanService( db=db, @@ -290,6 +296,7 @@ def get_agent_plan_service( plans=plans, settings_repo=settings_repo, work_sessions=work_sessions, + chat_sessions=chat_sessions, ) @@ -299,6 +306,7 @@ def get_agent_execute_service( jobs: BackgroundJobService = Depends(get_agent_background_job_service), plans: AgentPlanRepository = Depends(get_agent_plan_repository), work_sessions: AgentWorkSessionRepository = Depends(get_agent_work_session_repository), + chat_sessions: AgentChatSessionRepository = Depends(get_agent_chat_session_repository), ) -> ExecuteService: return ExecuteService( db=db, @@ -306,6 +314,7 @@ def get_agent_execute_service( jobs=jobs, plans=plans, work_sessions=work_sessions, + chat_sessions=chat_sessions, ) @@ -335,10 +344,19 @@ def get_agent_mcp_service( def get_agent_session_service( + db: AsyncSession = Depends(get_db), + event_bus: AgentEventBus = Depends(get_agent_event_bus), + chat_sessions: AgentChatSessionRepository = Depends(get_agent_chat_session_repository), action_logs: AgentActionLogRepository = Depends(get_agent_action_log_repository), work_sessions: AgentWorkSessionRepository = Depends(get_agent_work_session_repository), ) -> SessionService: - return SessionService(action_logs=action_logs, work_sessions=work_sessions) + return SessionService( + db=db, + event_bus=event_bus, + chat_sessions=chat_sessions, + action_logs=action_logs, + work_sessions=work_sessions, + ) async def get_current_user( diff --git a/app/src/fileflash/core/settings.py b/app/src/fileflash/core/settings.py index 71dde62..4e4de13 100644 --- a/app/src/fileflash/core/settings.py +++ b/app/src/fileflash/core/settings.py @@ -159,6 +159,8 @@ class Settings(BaseSettings): agent_tool_timeout_sec: int = Field(default=30, alias="AGENT_TOOL_TIMEOUT_SEC") agent_job_max_tokens: int = Field(default=50000, alias="AGENT_JOB_MAX_TOKENS") agent_job_max_tool_calls: int = Field(default=100, alias="AGENT_JOB_MAX_TOOL_CALLS") + agent_read_file_max_bytes: int = Field(default=1048576, alias="AGENT_READ_FILE_MAX_BYTES") + agent_skill_candidate_k: int = Field(default=3, alias="AGENT_SKILL_CANDIDATE_K") agent_compact_threshold: float = Field(default=0.75, alias="AGENT_COMPACT_THRESHOLD") agent_user_daily_limit: int = Field(default=50, alias="AGENT_USER_DAILY_LIMIT") agent_user_concurrent_limit: int = Field(default=2, alias="AGENT_USER_CONCURRENT_LIMIT") diff --git a/app/src/fileflash/models/__init__.py b/app/src/fileflash/models/__init__.py index 21cd3a5..2522385 100644 --- a/app/src/fileflash/models/__init__.py +++ b/app/src/fileflash/models/__init__.py @@ -2,6 +2,7 @@ from .tables import ( Acl, AgentActionLog, + AgentChatSession, AgentInboxMessage, AgentMcpServer, AgentMemory, @@ -41,6 +42,7 @@ __all__ = [ "Acl", "AgentActionLog", + "AgentChatSession", "AgentInboxMessage", "AgentMcpServer", "AgentMemory", diff --git a/app/src/fileflash/models/tables.py b/app/src/fileflash/models/tables.py index 5f4930c..e600fb6 100644 --- a/app/src/fileflash/models/tables.py +++ b/app/src/fileflash/models/tables.py @@ -11,6 +11,7 @@ ) from .tables_agent import ( AgentActionLog, + AgentChatSession, AgentInboxMessage, AgentMcpServer, AgentMemory, @@ -51,6 +52,7 @@ __all__ = [ "Acl", "AgentActionLog", + "AgentChatSession", "AgentInboxMessage", "AgentMcpServer", "AgentMemory", diff --git a/app/src/fileflash/models/tables_agent.py b/app/src/fileflash/models/tables_agent.py index 3ad9591..8d2c21d 100644 --- a/app/src/fileflash/models/tables_agent.py +++ b/app/src/fileflash/models/tables_agent.py @@ -348,6 +348,38 @@ class AgentWorkSession(Base): closed_at: Mapped[datetime | None] = mapped_column(DateTime) +class AgentChatSession(Base): + __tablename__ = "agent_chat_session" + __table_args__ = ( + Index( + "idx_agent_chat_session_user_deleted_updated", + "user_id", + "deleted_at", + text("updated_at DESC"), + ), + ) + + chat_session_id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True) + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("user.user_id", ondelete="CASCADE"), + nullable=False, + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + archived: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("FALSE")) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + + class AgentInboxMessage(Base): __tablename__ = "agent_inbox_message" __table_args__ = ( @@ -396,6 +428,7 @@ class AgentInboxMessage(Base): __all__ = [ "AgentActionLog", + "AgentChatSession", "AgentInboxMessage", "AgentMcpServer", "AgentMemory", diff --git a/app/src/fileflash/models/tables_worker.py b/app/src/fileflash/models/tables_worker.py index ef0955e..809a070 100644 --- a/app/src/fileflash/models/tables_worker.py +++ b/app/src/fileflash/models/tables_worker.py @@ -61,6 +61,11 @@ class BackgroundJob(Base): idempotency_key: Mapped[str | None] = mapped_column(String(255)) agent_phase: Mapped[str | None] = mapped_column(String(32)) cancel_requested_at: Mapped[datetime | None] = mapped_column(DateTime) + chat_session_id: Mapped[int | None] = mapped_column( + BigInteger, + ForeignKey("agent_chat_session.chat_session_id", ondelete="CASCADE"), + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime) requested_by: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("user.user_id", ondelete="SET NULL"), diff --git a/app/src/fileflash/repositories/__init__.py b/app/src/fileflash/repositories/__init__.py index c063ea1..7b3ba58 100644 --- a/app/src/fileflash/repositories/__init__.py +++ b/app/src/fileflash/repositories/__init__.py @@ -1,5 +1,6 @@ from .agent import ( AgentActionLogRepository, + AgentChatSessionRepository, AgentInboxMessageRepository, AgentMcpCatalogEntry, AgentMcpRepository, @@ -14,6 +15,7 @@ __all__ = [ "AgentActionLogRepository", + "AgentChatSessionRepository", "AgentInboxMessageRepository", "AgentMcpCatalogEntry", "AgentMcpRepository", diff --git a/app/src/fileflash/repositories/agent/__init__.py b/app/src/fileflash/repositories/agent/__init__.py index c551d06..b756721 100644 --- a/app/src/fileflash/repositories/agent/__init__.py +++ b/app/src/fileflash/repositories/agent/__init__.py @@ -1,4 +1,5 @@ from .action_log import AgentActionLogRepository +from .chat_session import AgentChatSessionRepository from .contracts import AgentMcpCatalogEntry, AgentMemoryActiveEntry, AgentSkillCatalogEntry from .inbox import AgentInboxMessageRepository from .mcp import AgentMcpRepository @@ -10,6 +11,7 @@ __all__ = [ "AgentActionLogRepository", + "AgentChatSessionRepository", "AgentInboxMessageRepository", "AgentMcpCatalogEntry", "AgentMcpRepository", diff --git a/app/src/fileflash/repositories/agent/chat_session.py b/app/src/fileflash/repositories/agent/chat_session.py new file mode 100644 index 0000000..c9cc12c --- /dev/null +++ b/app/src/fileflash/repositories/agent/chat_session.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import AgentChatSession, BackgroundJob + + +class AgentChatSessionRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, *, user_id: int, title: str) -> AgentChatSession: + now = datetime.now(UTC) + entity = AgentChatSession( + user_id=user_id, + title=title.strip()[:255] or "New session", + archived=False, + created_at=now, + updated_at=now, + ) + self.db.add(entity) + await self.db.flush() + return entity + + async def list_active(self, *, user_id: int) -> list[AgentChatSession]: + rows = await self.db.scalars( + select(AgentChatSession) + .where( + and_( + AgentChatSession.user_id == user_id, + AgentChatSession.deleted_at.is_(None), + ) + ) + .order_by(AgentChatSession.updated_at.desc(), AgentChatSession.chat_session_id.desc()) + ) + return list(rows) + + async def get_active( + self, + *, + chat_session_id: int, + user_id: int, + for_update: bool = False, + ) -> AgentChatSession | None: + statement = select(AgentChatSession).where( + and_( + AgentChatSession.chat_session_id == chat_session_id, + AgentChatSession.user_id == user_id, + AgentChatSession.deleted_at.is_(None), + ) + ) + if for_update: + statement = statement.with_for_update() + return await self.db.scalar(statement) + + async def update( + self, + *, + entity: AgentChatSession, + title: str | None = None, + archived: bool | None = None, + ) -> AgentChatSession: + if title is not None: + entity.title = title.strip()[:255] or entity.title + if archived is not None: + entity.archived = archived + entity.updated_at = datetime.now(UTC) + await self.db.flush() + return entity + + async def list_jobs(self, *, chat_session_id: int) -> list[BackgroundJob]: + rows = await self.db.scalars( + select(BackgroundJob) + .where( + and_( + BackgroundJob.chat_session_id == chat_session_id, + BackgroundJob.deleted_at.is_(None), + ) + ) + .order_by(BackgroundJob.created_at.asc(), BackgroundJob.job_id.asc()) + ) + return list(rows) + + async def attach_jobs( + self, + *, + chat_session_id: int, + user_id: int, + job_ids: list[int], + ) -> int: + if not job_ids: + return 0 + rows = await self.db.scalars( + select(BackgroundJob).where( + and_( + BackgroundJob.job_id.in_(job_ids), + BackgroundJob.requested_by == user_id, + BackgroundJob.task_type.in_(["agent.plan", "agent.execute"]), + BackgroundJob.deleted_at.is_(None), + ) + ) + ) + count = 0 + for job in rows: + if job.chat_session_id not in (None, chat_session_id): + continue + job.chat_session_id = chat_session_id + job.updated_at = datetime.now(UTC) + count += 1 + await self.db.flush() + return count + + +__all__ = ["AgentChatSessionRepository"] diff --git a/app/src/fileflash/routers/agent.py b/app/src/fileflash/routers/agent.py index 4cc869f..ee23052 100644 --- a/app/src/fileflash/routers/agent.py +++ b/app/src/fileflash/routers/agent.py @@ -14,26 +14,126 @@ get_agent_event_bus, get_agent_execute_service, get_agent_plan_service, + get_agent_session_service, get_current_user, ) from ..core.errors import ApiError, api_success from ..db.deps import get_db -from ..models import AgentActionLog, BackgroundJob -from ..models.enums import AgentInboxKind +from ..models import AgentActionLog, AgentInboxMessage, BackgroundJob +from ..models.enums import AgentInboxKind, AgentInboxStatus from ..models.tables_identity import User from ..schemas.agent import ( + AgentChatSessionList, + AttachAgentJobsRequest, + CreateAgentChatSessionRequest, AgentInboxMessageRequest, AgentInboxMessageResponse, AgentJobEvent, AgentJobEventType, ExecuteAgentRequest, + PatchAgentChatSessionRequest, PlanAgentRequest, ) -from ..services.agent import ExecuteService, PlanService +from ..schemas.common import PageQuery, PaginationMeta +from ..services.agent import ExecuteService, PlanService, SessionService router = APIRouter(prefix="/agent", tags=["agent"]) +@router.post("/chat-sessions") +async def create_agent_chat_session( + payload: CreateAgentChatSessionRequest, + current_user: Annotated[User, Depends(get_current_user)], + session_service: Annotated[SessionService, Depends(get_agent_session_service)], +): + data = await session_service.create_chat_session( + user_id=int(current_user.user_id), + payload=payload, + ) + return api_success(data=data.model_dump(by_alias=True), message="Agent chat session created") + + +@router.get("/chat-sessions") +async def list_agent_chat_sessions( + current_user: Annotated[User, Depends(get_current_user)], + session_service: Annotated[SessionService, Depends(get_agent_session_service)], + query: Annotated[PageQuery, Depends()], +): + rows = await session_service.list_chat_sessions(user_id=int(current_user.user_id)) + start = (query.page - 1) * query.per_page + end = start + query.per_page + total = len(rows) + total_pages = max(1, (total + query.per_page - 1) // query.per_page) + data = AgentChatSessionList( + items=rows[start:end], + pagination=PaginationMeta( + total_items=total, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + return api_success(data=data.model_dump(by_alias=True), message="Agent chat sessions loaded") + + +@router.get("/chat-sessions/{chat_session_id}") +async def get_agent_chat_session( + chat_session_id: str, + current_user: Annotated[User, Depends(get_current_user)], + session_service: Annotated[SessionService, Depends(get_agent_session_service)], +): + data = await session_service.get_chat_session( + user_id=int(current_user.user_id), + chat_session_id=_parse_chat_session_id(chat_session_id), + ) + return api_success(data=data.model_dump(by_alias=True), message="Agent chat session loaded") + + +@router.patch("/chat-sessions/{chat_session_id}") +async def patch_agent_chat_session( + chat_session_id: str, + payload: PatchAgentChatSessionRequest, + current_user: Annotated[User, Depends(get_current_user)], + session_service: Annotated[SessionService, Depends(get_agent_session_service)], +): + data = await session_service.patch_chat_session( + user_id=int(current_user.user_id), + chat_session_id=_parse_chat_session_id(chat_session_id), + payload=payload, + ) + return api_success(data=data.model_dump(by_alias=True), message="Agent chat session updated") + + +@router.delete("/chat-sessions/{chat_session_id}") +async def delete_agent_chat_session( + chat_session_id: str, + current_user: Annotated[User, Depends(get_current_user)], + session_service: Annotated[SessionService, Depends(get_agent_session_service)], +): + data = await session_service.delete_chat_session( + user_id=int(current_user.user_id), + chat_session_id=_parse_chat_session_id(chat_session_id), + ) + return api_success(data=data.model_dump(by_alias=True), message="Agent chat session deleted") + + +@router.post("/chat-sessions/{chat_session_id}/attach-jobs") +async def attach_agent_chat_session_jobs( + chat_session_id: str, + payload: AttachAgentJobsRequest, + current_user: Annotated[User, Depends(get_current_user)], + session_service: Annotated[SessionService, Depends(get_agent_session_service)], +): + data = await session_service.attach_jobs( + user_id=int(current_user.user_id), + chat_session_id=_parse_chat_session_id(chat_session_id), + job_ids=[_parse_job_id(job_id) for job_id in payload.job_ids], + ) + return api_success(data=data.model_dump(by_alias=True), message="Agent jobs attached") + + @router.post("/plan") async def plan_agent_task( payload: PlanAgentRequest, @@ -131,6 +231,7 @@ async def post_agent_job_message( raise ApiError(status_code=404, code=404, message="Job not found") kind = AgentInboxKind(payload.kind) + _validate_inbox_metadata(kind=kind, metadata=payload.metadata) reply_to_id: int | None = None if payload.reply_to is not None: try: @@ -155,7 +256,10 @@ async def post_agent_job_message( kind=payload.kind, accepted_at=msg.created_at, ) - return api_success(data=data.model_dump(by_alias=True), message="Message accepted") + return api_success( + data=data.model_dump(by_alias=True), + message=_inbox_response_message(kind), + ) def _inbox_payload_from_request(req: AgentInboxMessageRequest) -> dict[str, Any]: @@ -177,6 +281,43 @@ def _parse_job_id(raw: str) -> int: return parsed_job_id +def _parse_chat_session_id(raw: str) -> int: + try: + parsed_chat_session_id = int(raw) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message="Invalid chatSessionId") from exc + if parsed_chat_session_id <= 0: + raise ApiError(status_code=400, code=400, message="Invalid chatSessionId") + return parsed_chat_session_id + + +def _validate_inbox_metadata(*, kind: AgentInboxKind, metadata: dict[str, Any]) -> None: + if kind not in { + AgentInboxKind.CONTROL_SKIP, + AgentInboxKind.CONTROL_APPROVE, + AgentInboxKind.CONTROL_DENY, + }: + return + try: + step = int(metadata.get("step")) + except (TypeError, ValueError) as exc: + raise ApiError(status_code=422, code=422, message=f"{kind.value} requires metadata.step") from exc + if step <= 0: + raise ApiError(status_code=422, code=422, message=f"{kind.value} requires metadata.step") + + +def _inbox_response_message(kind: AgentInboxKind) -> str: + if kind in { + AgentInboxKind.CONTROL_PAUSE, + AgentInboxKind.CONTROL_CANCEL, + AgentInboxKind.CONTROL_SKIP, + AgentInboxKind.CONTROL_APPROVE, + AgentInboxKind.CONTROL_DENY, + }: + return "Control accepted; it will take effect after the current step finishes" + return "Message accepted" + + async def _agent_job_events_for_job( *, db: AsyncSession, @@ -213,12 +354,77 @@ async def _agent_job_events_for_job( ) for action_log in action_logs: events.extend(_tool_events(job=job, action_log=action_log)) + progress_event = _progress_event_from_action_logs(job=job, action_logs=action_logs) + if progress_event is not None: + events.append(progress_event) if terminal: events.append(_job_status_event(job)) + events.extend(await _agent_replay_events(db=db, job=job)) return events, terminal +async def _agent_replay_events(*, db: AsyncSession, job: BackgroundJob) -> list[AgentJobEvent]: + rows = list( + await db.scalars( + select(AgentInboxMessage) + .where(AgentInboxMessage.job_id == job.job_id) + .order_by(AgentInboxMessage.created_at.asc(), AgentInboxMessage.inbox_message_id.asc()) + ) + ) + if not rows: + return [] + + replay: list[AgentJobEvent] = [] + paused = False + paused_at = None + for msg in rows: + if not hasattr(msg, "kind"): + continue + if msg.kind == AgentInboxKind.CONTROL_PAUSE: + paused = True + paused_at = msg.created_at + elif msg.kind == AgentInboxKind.CONTROL_RESUME: + paused = False + paused_at = None + elif msg.kind == AgentInboxKind.ASK and msg.status == AgentInboxStatus.WAITING: + payload = dict(msg.payload_json or {}) + replay.append( + AgentJobEvent( + id=f"{job.job_id}:ask:{msg.inbox_message_id}", + job_id=str(job.job_id), + task_type=str(job.task_type), + type="agent.ask", + status="waiting_for_user", + agent_phase=job.agent_phase, + message=str(payload.get("prompt") or "Agent is waiting for your reply."), + data={ + "messageId": str(msg.inbox_message_id), + "prompt": str(payload.get("prompt") or ""), + "schema": payload.get("schema") if isinstance(payload.get("schema"), dict) else {}, + "timeoutSec": payload.get("timeoutSec") or 0, + }, + timestamp=msg.created_at, + ) + ) + + if paused and paused_at is not None: + replay.append( + AgentJobEvent( + id=f"{job.job_id}:paused:{paused_at.isoformat()}", + job_id=str(job.job_id), + task_type=str(job.task_type), + type="agent.paused", + status="paused", + agent_phase=job.agent_phase, + message="Agent pause is pending at the next step boundary.", + data={}, + timestamp=paused_at, + ) + ) + return replay + + def _job_status_event(job: BackgroundJob) -> AgentJobEvent: status = str(job.status) event_type = { @@ -288,6 +494,44 @@ def _tool_events(*, job: BackgroundJob, action_log: AgentActionLog) -> list[Agen return events +def _progress_event_from_action_logs( + *, + job: BackgroundJob, + action_logs: list[AgentActionLog], +) -> AgentJobEvent | None: + if not action_logs: + return None + last = max( + action_logs, + key=lambda item: ( + item.finished_at or item.started_at, + int(item.step_no), + ), + ) + result = dict(job.result or {}) + total_from_result = int(result.get("appliedActions") or 0) + int(result.get("skippedActions") or 0) + total = max(total_from_result, max(int(item.step_no) for item in action_logs), 1) + completed_steps = len([item for item in action_logs if item.status in {"succeeded", "failed"}]) + percent = min(100, int(round((completed_steps / total) * 100))) if total else None + timestamp = last.finished_at or last.started_at + return AgentJobEvent( + id=f"{job.job_id}:progress:{last.step_no}:{timestamp.isoformat()}", + job_id=str(job.job_id), + task_type=str(job.task_type), + type="agent.progress", + status=str(job.status), + agent_phase=job.agent_phase, + message=_tool_finished_message(last) if last.status in {"succeeded", "failed"} else _tool_started_message(last), + data={ + "step": int(last.step_no), + "total": total, + "message": _tool_finished_message(last) if last.status in {"succeeded", "failed"} else _tool_started_message(last), + **({"percent": percent} if percent is not None else {}), + }, + timestamp=timestamp, + ) + + def _job_status_message(job: BackgroundJob) -> str: status = str(job.status) if status == "pending": diff --git a/app/src/fileflash/schemas/__init__.py b/app/src/fileflash/schemas/__init__.py index 1e577d9..0588d57 100644 --- a/app/src/fileflash/schemas/__init__.py +++ b/app/src/fileflash/schemas/__init__.py @@ -8,6 +8,10 @@ ) from .agent import ( AgentApproval, + AgentChatMessage, + AgentChatSessionDetail, + AgentChatSessionItem, + AgentChatSessionList, AgentChosenSkill, AgentCostEstimate, AgentDataPolicy, @@ -22,9 +26,12 @@ AgentPlanResult, AgentProposedAction, AgentReasoningEffort, - CancelAgentResponse, + AttachAgentJobsRequest, + AttachAgentJobsResponse, + CreateAgentChatSessionRequest, ExecuteAgentRequest, ExecuteAgentResponse, + PatchAgentChatSessionRequest, PlanAgentRequest, PlanAgentResponse, ) @@ -199,6 +206,12 @@ "AdminFileAuditOwner", "AdminFileLatestScan", "ApiResponse", + "AgentChatMessage", + "AgentChatSessionDetail", + "AgentChatSessionItem", + "AgentChatSessionList", + "AttachAgentJobsRequest", + "AttachAgentJobsResponse", "BatchFilesRequest", "BatchFilesResponse", "BatchDownloadRequest", @@ -206,7 +219,6 @@ "BroadcastNotificationRequest", "BreakdownDetail", "CamelModel", - "CancelAgentResponse", "ChangePasswordRequest", "ClearRecycleBinResponse", "ContentItem", @@ -214,6 +226,7 @@ "CopyFileResponse", "CopyFolderRequest", "CreateFolderRequest", + "CreateAgentChatSessionRequest", "CreatePermissionRequest", "CreateShareRequest", "CreateUserGroupRequest", @@ -262,6 +275,7 @@ "NotificationItem", "NotificationsList", "PageQuery", + "PatchAgentChatSessionRequest", "PaginatedData", "PaginationMeta", "PermissionItem", diff --git a/app/src/fileflash/schemas/agent.py b/app/src/fileflash/schemas/agent.py index 47c273d..98202de 100644 --- a/app/src/fileflash/schemas/agent.py +++ b/app/src/fileflash/schemas/agent.py @@ -5,7 +5,7 @@ from pydantic import Field -from .common import CamelModel +from .common import CamelModel, PaginatedData AgentExecutionPolicy = Literal["planOnly", "confirm", "autopilot"] AgentActionSideEffect = Literal["read", "write"] @@ -60,6 +60,7 @@ class AgentPlanContext(CamelModel): class PlanAgentRequest(CamelModel): + chat_session_id: str = Field(min_length=1) input: str = Field(min_length=1, max_length=4_000) context: AgentPlanContext execution_policy: AgentExecutionPolicy = "confirm" @@ -120,6 +121,7 @@ class AgentApproval(CamelModel): class ExecuteAgentRequest(CamelModel): + chat_session_id: str = Field(min_length=1) plan_job_id: str plan_hash: str approval: AgentApproval @@ -131,12 +133,6 @@ class ExecuteAgentResponse(CamelModel): task_type: Literal["agent.execute"] = "agent.execute" -class CancelAgentResponse(CamelModel): - job_id: str - status: str - canceled_at: datetime - - class AgentExecutionResult(CamelModel): plan_job_id: str execute_job_id: str @@ -184,11 +180,63 @@ class AgentInboxMessageResponse(CamelModel): accepted_at: datetime +class AgentChatMessage(CamelModel): + id: str + role: Literal["user", "agent"] + content: str = "" + status: str + plan_job_id: str | None = None + plan_hash: str | None = None + plan_result: dict[str, Any] | None = None + execute_job_id: str | None = None + execute_result: dict[str, Any] | None = None + events: list[AgentJobEvent] = Field(default_factory=list) + error_message: str | None = None + timestamp: datetime + pending_ask: dict[str, Any] | None = None + + +class AgentChatSessionItem(CamelModel): + chat_session_id: str + title: str + archived: bool = False + created_at: datetime + updated_at: datetime + + +class AgentChatSessionDetail(AgentChatSessionItem): + messages: list[AgentChatMessage] = Field(default_factory=list) + + +AgentChatSessionList = PaginatedData[AgentChatSessionItem] + + +class CreateAgentChatSessionRequest(CamelModel): + title: str | None = Field(default=None, max_length=255) + + +class PatchAgentChatSessionRequest(CamelModel): + title: str | None = Field(default=None, max_length=255) + archived: bool | None = None + + +class AttachAgentJobsRequest(CamelModel): + job_ids: list[str] = Field(default_factory=list) + + +class AttachAgentJobsResponse(CamelModel): + attached_count: int = Field(ge=0) + + __all__ = [ "AgentActionSideEffect", "AgentApproval", "AgentChosenSkill", "AgentCostEstimate", + "AgentChatMessage", + "AgentChatSessionDetail", + "AgentChatSessionItem", + "AgentChatSessionList", "AgentDataPolicy", "AgentExecutionPolicy", "AgentExecutionResult", @@ -205,9 +253,12 @@ class AgentInboxMessageResponse(CamelModel): "AgentProposedAction", "AgentReasoningEffort", "AgentRiskLevel", - "CancelAgentResponse", + "AttachAgentJobsRequest", + "AttachAgentJobsResponse", + "CreateAgentChatSessionRequest", "ExecuteAgentRequest", "ExecuteAgentResponse", + "PatchAgentChatSessionRequest", "PlanAgentRequest", "PlanAgentResponse", ] diff --git a/app/src/fileflash/services/agent/execute_service.py b/app/src/fileflash/services/agent/execute_service.py index e3f7c41..81f9bbe 100644 --- a/app/src/fileflash/services/agent/execute_service.py +++ b/app/src/fileflash/services/agent/execute_service.py @@ -7,7 +7,7 @@ from ...core.errors import ApiError from ...core.settings import Settings from ...models import BackgroundJob -from ...repositories import AgentPlanRepository, AgentWorkSessionRepository +from ...repositories import AgentChatSessionRepository, AgentPlanRepository, AgentWorkSessionRepository from ...schemas.agent import AgentProposedAction, ExecuteAgentRequest, ExecuteAgentResponse from ..background_jobs import BackgroundJobService @@ -21,12 +21,14 @@ def __init__( jobs: BackgroundJobService, plans: AgentPlanRepository, work_sessions: AgentWorkSessionRepository, + chat_sessions: AgentChatSessionRepository, ) -> None: self.db = db self.settings = settings self.jobs = jobs self.plans = plans self.work_sessions = work_sessions + self.chat_sessions = chat_sessions async def enqueue_execute( self, @@ -37,6 +39,14 @@ async def enqueue_execute( if not self.settings.agent_enabled: raise ApiError(status_code=503, code=503, message="Agent runtime is disabled") + chat_session_id = _parse_chat_session_id(payload.chat_session_id) + chat_session = await self.chat_sessions.get_active( + chat_session_id=chat_session_id, + user_id=user_id, + ) + if chat_session is None: + raise ApiError(status_code=404, code=404, message="Agent chat session not found") + plan_job_id = _parse_job_id(payload.plan_job_id) plan_job = await self.db.scalar( select(BackgroundJob).where( @@ -44,6 +54,8 @@ async def enqueue_execute( BackgroundJob.job_id == plan_job_id, BackgroundJob.requested_by == user_id, BackgroundJob.task_type == "agent.plan", + BackgroundJob.chat_session_id == chat_session_id, + BackgroundJob.deleted_at.is_(None), ) ) ) @@ -98,6 +110,7 @@ async def enqueue_execute( max_attempts=1, priority=100, agent_phase="executing", + chat_session_id=chat_session_id, ) return ExecuteAgentResponse( job_id=str(job.job_id), @@ -116,6 +129,16 @@ def _parse_job_id(raw: str) -> int: return value +def _parse_chat_session_id(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message="Invalid chatSessionId") from exc + if value <= 0: + raise ApiError(status_code=400, code=400, message="Invalid chatSessionId") + return value + + def _high_risk_actions(raw_actions: object) -> list[dict[str, object]]: if not isinstance(raw_actions, list): return [] diff --git a/app/src/fileflash/services/agent/plan_service.py b/app/src/fileflash/services/agent/plan_service.py index f5fc3a5..89881f7 100644 --- a/app/src/fileflash/services/agent/plan_service.py +++ b/app/src/fileflash/services/agent/plan_service.py @@ -8,7 +8,12 @@ from ...core.errors import ApiError from ...core.settings import Settings from ...models import BackgroundJob -from ...repositories import AgentPlanRepository, AgentSettingsRepository, AgentWorkSessionRepository +from ...repositories import ( + AgentChatSessionRepository, + AgentPlanRepository, + AgentSettingsRepository, + AgentWorkSessionRepository, +) from ...schemas.agent import PlanAgentRequest, PlanAgentResponse from ..background_jobs import BackgroundJobService @@ -23,6 +28,7 @@ def __init__( plans: AgentPlanRepository, settings_repo: AgentSettingsRepository, work_sessions: AgentWorkSessionRepository, + chat_sessions: AgentChatSessionRepository, ) -> None: self.db = db self.settings = settings @@ -30,6 +36,7 @@ def __init__( self.plans = plans self.settings_repo = settings_repo self.work_sessions = work_sessions + self.chat_sessions = chat_sessions async def enqueue_plan(self, *, user_id: int, payload: PlanAgentRequest) -> PlanAgentResponse: if not self.settings.agent_enabled: @@ -47,6 +54,13 @@ async def enqueue_plan(self, *, user_id: int, payload: PlanAgentRequest) -> Plan raise ApiError(status_code=400, code=400, message="Agent maxSteps exceeds server limit") await self._enforce_limits(user_id=user_id) + chat_session_id = _parse_chat_session_id(payload.chat_session_id) + chat_session = await self.chat_sessions.get_active( + chat_session_id=chat_session_id, + user_id=user_id, + ) + if chat_session is None: + raise ApiError(status_code=404, code=404, message="Agent chat session not found") job = await self.jobs.enqueue( self.db, task_type="agent.plan", @@ -55,6 +69,7 @@ async def enqueue_plan(self, *, user_id: int, payload: PlanAgentRequest) -> Plan max_attempts=1, priority=100, agent_phase="planning", + chat_session_id=chat_session_id, ) return PlanAgentResponse( job_id=str(job.job_id), @@ -87,3 +102,13 @@ async def _enforce_limits(self, *, user_id: int) -> None: ) if int(daily or 0) >= self.settings.agent_user_daily_limit: raise ApiError(status_code=429, code=429, message="Agent daily job limit exceeded") + + +def _parse_chat_session_id(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message="Invalid chatSessionId") from exc + if value <= 0: + raise ApiError(status_code=400, code=400, message="Invalid chatSessionId") + return value diff --git a/app/src/fileflash/services/agent/session_service.py b/app/src/fileflash/services/agent/session_service.py index 190cda8..cf33b4d 100644 --- a/app/src/fileflash/services/agent/session_service.py +++ b/app/src/fileflash/services/agent/session_service.py @@ -1,20 +1,256 @@ from __future__ import annotations -from ...repositories import AgentActionLogRepository, AgentWorkSessionRepository +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...agents.harness.event_bus import AgentEventBus +from ...agents.harness.inbox import AgentInbox +from ...core.errors import ApiError +from ...models import AgentChatSession, AgentInboxMessage, BackgroundJob +from ...models.enums import AgentInboxKind, AgentInboxStatus +from ...repositories import ( + AgentActionLogRepository, + AgentChatSessionRepository, + AgentWorkSessionRepository, +) +from ...schemas.agent import ( + AgentChatMessage, + AgentChatSessionDetail, + AgentChatSessionItem, + AttachAgentJobsResponse, + CreateAgentChatSessionRequest, + PatchAgentChatSessionRequest, +) class SessionService: def __init__( self, *, + db: AsyncSession, + event_bus: AgentEventBus, + chat_sessions: AgentChatSessionRepository, action_logs: AgentActionLogRepository, work_sessions: AgentWorkSessionRepository, ) -> None: + self.db = db + self.event_bus = event_bus + self.chat_sessions = chat_sessions self.action_logs = action_logs self.work_sessions = work_sessions - async def get_work_session(self, *args, **kwargs): - raise NotImplementedError("Agent session service is scaffolded only in this stage") + async def create_chat_session( + self, + *, + user_id: int, + payload: CreateAgentChatSessionRequest, + ) -> AgentChatSessionItem: + entity = await self.chat_sessions.create( + user_id=user_id, + title=payload.title or "New session", + ) + await self.db.commit() + return _session_item(entity) + + async def list_chat_sessions(self, *, user_id: int) -> list[AgentChatSessionItem]: + rows = await self.chat_sessions.list_active(user_id=user_id) + return [_session_item(row) for row in rows] + + async def get_chat_session(self, *, user_id: int, chat_session_id: int) -> AgentChatSessionDetail: + entity = await self.chat_sessions.get_active( + user_id=user_id, + chat_session_id=chat_session_id, + ) + if entity is None: + raise ApiError(status_code=404, code=404, message="Agent chat session not found") + jobs = await self.chat_sessions.list_jobs(chat_session_id=chat_session_id) + pending_asks = await self._pending_asks_by_job(job_ids=[int(job.job_id) for job in jobs]) + item = _session_item(entity) + return AgentChatSessionDetail( + **item.model_dump(), + messages=_messages_from_jobs(jobs=jobs, pending_asks=pending_asks), + ) + + async def patch_chat_session( + self, + *, + user_id: int, + chat_session_id: int, + payload: PatchAgentChatSessionRequest, + ) -> AgentChatSessionItem: + entity = await self.chat_sessions.get_active( + user_id=user_id, + chat_session_id=chat_session_id, + ) + if entity is None: + raise ApiError(status_code=404, code=404, message="Agent chat session not found") + updated = await self.chat_sessions.update( + entity=entity, + title=payload.title, + archived=payload.archived, + ) + await self.db.commit() + return _session_item(updated) + + async def attach_jobs( + self, + *, + user_id: int, + chat_session_id: int, + job_ids: list[int], + ) -> AttachAgentJobsResponse: + entity = await self.chat_sessions.get_active( + user_id=user_id, + chat_session_id=chat_session_id, + ) + if entity is None: + raise ApiError(status_code=404, code=404, message="Agent chat session not found") + count = await self.chat_sessions.attach_jobs( + chat_session_id=chat_session_id, + user_id=user_id, + job_ids=job_ids, + ) + entity.updated_at = datetime.now(UTC) + await self.db.commit() + return AttachAgentJobsResponse(attached_count=count) + + async def delete_chat_session(self, *, user_id: int, chat_session_id: int) -> AgentChatSessionItem: + entity = await self.chat_sessions.get_active( + user_id=user_id, + chat_session_id=chat_session_id, + for_update=True, + ) + if entity is None: + raise ApiError(status_code=404, code=404, message="Agent chat session not found") + now = datetime.now(UTC) + entity.deleted_at = now + entity.updated_at = now + + jobs = await self.chat_sessions.list_jobs(chat_session_id=chat_session_id) + inbox = AgentInbox(db=self.db, event_bus=self.event_bus) + for job in jobs: + job.deleted_at = now + job.updated_at = now + if _is_unfinished_job(job): + job.cancel_requested_at = job.cancel_requested_at or now + await inbox.handle( + job_id=int(job.job_id), + kind=AgentInboxKind.CONTROL_CANCEL, + payload={"metadata": {"source": "chatSession.delete"}}, + ) + await self.db.commit() + return _session_item(entity) + + async def _pending_asks_by_job(self, *, job_ids: list[int]) -> dict[int, dict[str, Any]]: + if not job_ids: + return {} + rows = await self.db.scalars( + select(AgentInboxMessage).where( + and_( + AgentInboxMessage.job_id.in_(job_ids), + AgentInboxMessage.kind == AgentInboxKind.ASK, + AgentInboxMessage.status == AgentInboxStatus.WAITING, + ) + ) + ) + out: dict[int, dict[str, Any]] = {} + for msg in rows: + payload = dict(msg.payload_json or {}) + out[int(msg.job_id)] = { + "messageId": str(msg.inbox_message_id), + "prompt": str(payload.get("prompt") or ""), + "schema": payload.get("schema") if isinstance(payload.get("schema"), dict) else {}, + "timeoutSec": int(float(payload.get("timeoutSec") or 0)), + "askedAt": msg.created_at.isoformat(), + } + return out + + +def _session_item(entity: AgentChatSession) -> AgentChatSessionItem: + return AgentChatSessionItem( + chat_session_id=str(entity.chat_session_id), + title=entity.title, + archived=bool(entity.archived), + created_at=entity.created_at, + updated_at=entity.updated_at, + ) + + +def _is_unfinished_job(job: BackgroundJob) -> bool: + return str(job.status) in {"pending", "queued", "running", "retrying", "paused"} + + +def _messages_from_jobs( + *, + jobs: list[BackgroundJob], + pending_asks: dict[int, dict[str, Any]], +) -> list[AgentChatMessage]: + plan_messages: dict[str, AgentChatMessage] = {} + messages: list[AgentChatMessage] = [] + + for job in jobs: + if job.task_type != "agent.plan": + continue + payload = dict(job.payload or {}) + result = dict(job.result or {}) + user_msg = AgentChatMessage( + id=f"job-{job.job_id}:user", + role="user", + content=str(payload.get("input") or ""), + status="succeeded", + timestamp=job.created_at, + ) + plan_result = result if job.status == "succeeded" and result else None + agent_msg = AgentChatMessage( + id=f"job-{job.job_id}:agent", + role="agent", + content="", + status=_message_status(job), + plan_job_id=str(job.job_id), + plan_hash=str(plan_result.get("planHash") or "") if plan_result else None, + plan_result=plan_result, + error_message=job.error_message, + timestamp=job.created_at, + pending_ask=pending_asks.get(int(job.job_id)), + ) + if agent_msg.pending_ask: + agent_msg.status = "waiting_for_user" + messages.extend([user_msg, agent_msg]) + plan_messages[str(job.job_id)] = agent_msg + + for job in jobs: + if job.task_type != "agent.execute": + continue + payload = dict(job.payload or {}) + plan_job_id = str(payload.get("planJobId") or "") + agent_msg = plan_messages.get(plan_job_id) + if agent_msg is None: + agent_msg = AgentChatMessage( + id=f"job-{job.job_id}:agent", + role="agent", + content="", + status=_message_status(job), + timestamp=job.created_at, + ) + messages.append(agent_msg) + agent_msg.execute_job_id = str(job.job_id) + if job.status == "succeeded" and job.result: + agent_msg.execute_result = dict(job.result or {}) + agent_msg.status = _message_status(job) + if job.error_message: + agent_msg.error_message = job.error_message + if pending_asks.get(int(job.job_id)): + agent_msg.pending_ask = pending_asks[int(job.job_id)] + agent_msg.status = "waiting_for_user" + + return messages + - async def update_checkpoint(self, *args, **kwargs): - raise NotImplementedError("Agent session service is scaffolded only in this stage") +def _message_status(job: BackgroundJob) -> str: + status = str(job.status or "") + if status in {"pending", "running", "succeeded", "failed", "canceled", "paused"}: + return status + return "running" diff --git a/app/src/fileflash/services/background_jobs.py b/app/src/fileflash/services/background_jobs.py index 7d45fec..e8eb9fe 100644 --- a/app/src/fileflash/services/background_jobs.py +++ b/app/src/fileflash/services/background_jobs.py @@ -31,6 +31,7 @@ async def enqueue( max_attempts: int = 5, priority: int = 100, agent_phase: str | None = None, + chat_session_id: int | None = None, ) -> BackgroundJob: now = datetime.now(UTC) normalized_payload = jsonable_encoder(payload) @@ -46,6 +47,7 @@ async def enqueue( trace_id=str(uuid.uuid4()), idempotency_key=idempotency_key, agent_phase=agent_phase, + chat_session_id=chat_session_id, requested_by=requested_by, priority=priority, ) diff --git a/app/tests/test_agent_a_end_to_end.py b/app/tests/test_agent_a_end_to_end.py index 377fcbc..0e152c8 100644 --- a/app/tests/test_agent_a_end_to_end.py +++ b/app/tests/test_agent_a_end_to_end.py @@ -31,6 +31,7 @@ def _execute_job() -> BackgroundJob: task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "500", "planHash": "sha256:test", "approval": { diff --git a/app/tests/test_agent_permission.py b/app/tests/test_agent_permission.py new file mode 100644 index 0000000..f80970e --- /dev/null +++ b/app/tests/test_agent_permission.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import pytest + +from fileflash.agents.harness.permission import EffectivePermission, PermissionResolver +from fileflash.models import AgentUserSetting +from fileflash.schemas.agent import AgentDataPolicy, PlanAgentRequest + + +def _request(**overrides) -> PlanAgentRequest: + base = { + "chatSessionId": "1", + "input": "list my files", + "context": {"rootFolderId": "root"}, + } + base.update(overrides) + return PlanAgentRequest.model_validate(base) + + +@pytest.mark.asyncio +async def test_effective_defaults_when_no_setting_no_skill(): + resolver = PermissionResolver() + perm = await resolver.effective( + request=_request(), + setting=None, + skill=None, + high_risk_confirmed=False, + ) + assert perm.execution_policy == "confirm" + assert perm.deny_read_content is True # default allow_file_content=False + assert perm.skill_key is None + assert "drive.listFolder" in perm.allowed_tools + + +@pytest.mark.asyncio +async def test_effective_setting_overrides_data_policy_take_strictest(): + # Setting says allow_file_content=True; request says False -> False wins (取最严) + setting = AgentUserSetting( + user_id=1, + default_execution_policy="confirm", + default_data_policy_json={"allowFileContent": True, "maxReadBytes": 2097152}, + ) + perm = await PermissionResolver().effective( + request=_request(dataPolicy={"allowFileContent": False}), + setting=setting, + skill=None, + high_risk_confirmed=False, + ) + assert perm.deny_read_content is True + + +@pytest.mark.asyncio +async def test_effective_setting_denies_content_even_when_request_allows(): + # Setting allow_file_content=False; request True -> False wins + setting = AgentUserSetting( + user_id=1, + default_data_policy_json={"allowFileContent": False, "maxReadBytes": 0}, + ) + perm = await PermissionResolver().effective( + request=_request(dataPolicy={"allowFileContent": True}), + setting=setting, + skill=None, + high_risk_confirmed=False, + ) + assert perm.deny_read_content is True + assert perm.data_policy.max_read_bytes == 0 + + +@pytest.mark.asyncio +async def test_effective_mime_intersection_empty_denies_content(): + setting = AgentUserSetting( + user_id=1, + default_data_policy_json={"allowedMimeTypes": ["image/*"]}, + ) + perm = await PermissionResolver().effective( + request=_request(dataPolicy={"allowedMimeTypes": ["text/*"]}), + setting=setting, + skill=None, + high_risk_confirmed=False, + ) + assert perm.data_policy.allowed_mime_types == [] + assert perm.deny_read_content is True + + +# --------------------------------------------------------------------------- +# Task 2: PolicyGuard.evaluate — single permission choke point +# --------------------------------------------------------------------------- + +from unittest.mock import AsyncMock # noqa: E402 + +from fileflash.agents.harness.policy import PolicyGuard, PolicyDecision # noqa: E402 +from fileflash.agents.harness.tool_registry import ToolContext # noqa: E402 +from fileflash.schemas.agent import AgentProposedAction # noqa: E402 + +REGISTRY_NAMES = __import__( + "fileflash.agents.harness.tool_registry", fromlist=["REGISTRY"] +).REGISTRY.all_names() + + +def _ctx_with_mime(mime: str = "text/plain") -> ToolContext: + db = AsyncMock() + db.scalar = AsyncMock( + return_value=type( + "F", + (), + {"mime_type": mime, "file_ext": None, "file_name": "x.txt"}, + )() + ) + return ToolContext( + db=db, user_id=1, file_service=None, folder_service=None, storage_reader=None + ) + + +def _perm( + *, + allowed_tools=None, + deny_read=False, + allow_content=True, + mimes=None, + high_risk=False, + policy="confirm", +): + from fileflash.agents.harness.permission import EffectivePermission + from fileflash.schemas.agent import AgentDataPolicy + + return EffectivePermission( + execution_policy=policy, + data_policy=AgentDataPolicy( + allow_file_content=allow_content, + max_read_bytes=1048576, + allowed_mime_types=mimes if mimes is not None else ["*/*"], + ), + allowed_tools=frozenset(allowed_tools) if allowed_tools else frozenset(REGISTRY_NAMES), + skill_key=None, + deny_read_content=deny_read, + high_risk_confirmed=high_risk, + ) + + +@pytest.mark.asyncio +async def test_evaluate_unknown_tool_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.noSuch", input={}, side_effect="read"), + permission=_perm(), + phase="executing", + ) + assert decision.allowed is False + assert any("unknown" in r.lower() or "unsupported" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_tool_not_in_whitelist_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.deleteFile", input={"fileId": "1"}, side_effect="write"), + permission=_perm(allowed_tools=["drive.listFolder"]), + phase="executing", + ) + assert decision.allowed is False + assert any("skill" in r.lower() or "permitted" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_readfile_blocked_when_content_disabled(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.readFile", input={"fileId": "1"}, side_effect="read"), + permission=_perm(deny_read=True, allowed_tools=["drive.readFile"]), + phase="executing", + ) + assert decision.allowed is False + assert any("content" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_readfile_mime_not_allowed_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(mime="application/pdf"), + action=AgentProposedAction(step=1, tool="drive.readFile", input={"fileId": "1"}, side_effect="read"), + permission=_perm(allowed_tools=["drive.readFile"], mimes=["text/*"]), + phase="executing", + ) + assert decision.allowed is False + assert any("mime" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_high_risk_without_confirmation_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.deleteFile", input={"fileId": "1"}, side_effect="write", risk_level="high"), + permission=_perm(allowed_tools=["drive.deleteFile"], high_risk=False), + phase="executing", + ) + assert decision.allowed is False + assert any("confirmation" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_planonly_executing_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.createFolder", input={"name": "x"}, side_effect="write", risk_level="medium"), + permission=_perm(allowed_tools=["drive.createFolder"], policy="planOnly"), + phase="executing", + ) + assert decision.allowed is False + assert any("planonly" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_allowed_read_tool_passes(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.listFolder", input={"folderId": "root"}, side_effect="read"), + permission=_perm(allowed_tools=["drive.listFolder"]), + phase="executing", + ) + assert decision.allowed is True + + +@pytest.mark.asyncio +async def test_evaluate_readfile_max_read_bytes_exceeded_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction( + step=1, + tool="drive.readFile", + input={"fileId": "1", "maxBytes": 2097152}, + side_effect="read", + ), + permission=_perm(allowed_tools=["drive.readFile"]), + phase="executing", + ) + assert decision.allowed is False + assert any("max_read_bytes" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_readfile_small_read_at_large_offset_allowed(): + # Regression guard for the old `+ offset` logic: reading 1 byte at offset + # 1MB with max_read_bytes=1MB must be ALLOWED (bytes read, not position). + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction( + step=1, + tool="drive.readFile", + input={"fileId": "1", "maxBytes": 1, "offset": 1048576}, + side_effect="read", + ), + permission=_perm(allowed_tools=["drive.readFile"]), + phase="executing", + ) + assert decision.allowed is True + + +@pytest.mark.asyncio +async def test_evaluate_planonly_planning_allowed(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.listFolder", input={"folderId": "root"}, side_effect="read"), + permission=_perm(allowed_tools=["drive.listFolder"], policy="planOnly"), + phase="planning", + ) + assert decision.allowed is True + + +# --------------------------------------------------------------------------- +# Task 4: regression guard for _merge_data_policy take-strictest bytes +# --------------------------------------------------------------------------- + +from fileflash.agents.harness.permission import _merge_data_policy # noqa: E402 + + +def test_setting_default_data_policy_merges_take_strictest_bytes(): + setting = AgentUserSetting( + user_id=1, + default_data_policy_json={"allowFileContent": True, "maxReadBytes": 512}, + ) + merged = _merge_data_policy( + AgentDataPolicy(allow_file_content=True, max_read_bytes=4096, allowed_mime_types=["*/*"]), + setting, + ) + assert merged.max_read_bytes == 512 # min wins diff --git a/app/tests/test_agent_plan_execute_runtime.py b/app/tests/test_agent_plan_execute_runtime.py index 245bf4b..ffaa6e2 100644 --- a/app/tests/test_agent_plan_execute_runtime.py +++ b/app/tests/test_agent_plan_execute_runtime.py @@ -10,7 +10,6 @@ import pytest from fileflash.agents.harness.event_bus import AgentEventEnvelope -from fileflash.agents.harness.policy import PolicyGuard, classify_tool_risk from fileflash.agents.harness.router import ToolCall, ToolRouter from fileflash.agents.runtime import execute_runner as execute_module from fileflash.agents.runtime import plan_runner as plan_module @@ -32,7 +31,7 @@ class DummyDb: def __init__(self) -> None: - self.scalar = AsyncMock() + self.scalar = AsyncMock(return_value=None) self.execute = AsyncMock() self.scalars = AsyncMock() self.get = AsyncMock() @@ -64,6 +63,11 @@ async def enqueue(self, db, **kwargs): # noqa: ANN001 ) +class FakeChatSessions: + async def get_active(self, **_kwargs): # noqa: ANN003 + return SimpleNamespace(chat_session_id=1, user_id=7, deleted_at=None) + + def settings(**overrides): base = { "agent_enabled": True, @@ -74,6 +78,7 @@ def settings(**overrides): "agent_user_daily_limit": 50, "agent_llm_base_url": None, "agent_llm_plan_max_tokens": 8192, + "agent_skill_candidate_k": 3, } base.update(overrides) return SimpleNamespace(**base) @@ -91,9 +96,11 @@ async def test_plan_enqueue_returns_frontend_shape_and_sets_phase(): plans=AgentPlanRepository(db), # type: ignore[arg-type] settings_repo=AgentSettingsRepository(db), # type: ignore[arg-type] work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + chat_sessions=FakeChatSessions(), # type: ignore[arg-type] ) payload = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "整理当前文件夹", "context": { "rootFolderId": "root", @@ -136,9 +143,11 @@ async def test_plan_enqueue_rejects_max_steps_above_server_limit_in_non_dev(): plans=AgentPlanRepository(db), # type: ignore[arg-type] settings_repo=AgentSettingsRepository(db), # type: ignore[arg-type] work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + chat_sessions=FakeChatSessions(), # type: ignore[arg-type] ) payload = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "整理当前文件夹", "context": { "rootFolderId": "root", @@ -175,9 +184,11 @@ async def test_plan_enqueue_allows_max_steps_above_server_limit_in_dev(): plans=AgentPlanRepository(db), # type: ignore[arg-type] settings_repo=AgentSettingsRepository(db), # type: ignore[arg-type] work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + chat_sessions=FakeChatSessions(), # type: ignore[arg-type] ) payload = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "整理当前文件夹", "context": { "rootFolderId": "root", @@ -644,9 +655,11 @@ async def test_execute_rejects_high_risk_plan_without_confirmation(): jobs=FakeJobs(), # type: ignore[arg-type] plans=plans, work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + chat_sessions=FakeChatSessions(), # type: ignore[arg-type] ) payload = ExecuteAgentRequest.model_validate( { + "chatSessionId": "1", "planJobId": "99", "planHash": "sha256:test", "approval": {"confirmedBy": "7", "confirmedAt": datetime.now(UTC).isoformat()}, @@ -684,9 +697,11 @@ async def test_execute_enqueue_serializes_approval_datetime_as_json_string(): jobs=jobs, # type: ignore[arg-type] plans=plans, work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + chat_sessions=FakeChatSessions(), # type: ignore[arg-type] ) payload = ExecuteAgentRequest.model_validate( { + "chatSessionId": "1", "planJobId": "99", "planHash": "sha256:test", "approval": {"confirmedBy": "7", "confirmedAt": "2026-05-25T10:00:00Z"}, @@ -737,9 +752,11 @@ async def test_execute_rejects_repeat_when_existing_execute_job_exists(): jobs=jobs, # type: ignore[arg-type] plans=plans, work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + chat_sessions=FakeChatSessions(), # type: ignore[arg-type] ) payload = ExecuteAgentRequest.model_validate( { + "chatSessionId": "1", "planJobId": "99", "planHash": "sha256:test", "approval": {"confirmedBy": "7", "confirmedAt": "2026-05-25T10:00:00Z"}, @@ -757,7 +774,7 @@ async def test_execute_rejects_repeat_when_existing_execute_job_exists(): @pytest.mark.asyncio async def test_plan_runner_generates_stable_hash(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -784,6 +801,7 @@ async def test_plan_runner_generates_stable_hash(monkeypatch: pytest.MonkeyPatch client = SimpleNamespace(create_plan=planner) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "organize", "context": { "rootFolderId": "root", @@ -829,7 +847,7 @@ async def test_plan_runner_generates_stable_hash(monkeypatch: pytest.MonkeyPatch @pytest.mark.asyncio async def test_plan_runner_ignores_requested_max_steps_in_dev(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -855,6 +873,7 @@ async def test_plan_runner_ignores_requested_max_steps_in_dev(monkeypatch: pytes ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "organize", "context": { "rootFolderId": "root", @@ -895,7 +914,7 @@ async def test_plan_runner_ignores_requested_max_steps_in_dev(monkeypatch: pytes @pytest.mark.asyncio async def test_plan_runner_commits_after_upsert(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -910,6 +929,7 @@ async def test_plan_runner_commits_after_upsert(monkeypatch: pytest.MonkeyPatch) ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "organize", "context": { "rootFolderId": "root", @@ -940,7 +960,7 @@ async def test_plan_runner_commits_after_upsert(monkeypatch: pytest.MonkeyPatch) @pytest.mark.asyncio async def test_plan_runner_rolls_back_when_commit_fails(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -955,6 +975,7 @@ async def test_plan_runner_rolls_back_when_commit_fails(monkeypatch: pytest.Monk ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "organize", "context": { "rootFolderId": "root", @@ -989,7 +1010,7 @@ async def test_plan_runner_rolls_back_when_commit_fails(monkeypatch: pytest.Monk async def test_plan_runner_propagates_llm_output_errors_without_fallback( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1010,6 +1031,7 @@ async def test_plan_runner_propagates_llm_output_errors_without_fallback( ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "organize", "context": { "rootFolderId": "root", @@ -1038,11 +1060,66 @@ async def test_plan_runner_propagates_llm_output_errors_without_fallback( assert exc.value.status_code == 502 +@pytest.mark.asyncio +async def test_plan_runner_asks_then_replans_when_plan_is_not_executable( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) + monkeypatch.setattr( + plan_module, + "_collect_context_metadata", + AsyncMock(return_value={"scope": "currentFolder", "rootFolderId": "root", "files": [], "folders": []}), + ) + monkeypatch.setattr(plan_module, "_upsert_agent_plan", AsyncMock(return_value=None)) + ask_mock = AsyncMock(return_value={"clarification": "Use the current folder."}) + monkeypatch.setattr(PlanRunner, "_ask", ask_mock) + + planner = AsyncMock( + side_effect=[ + {"summary": "not executable"}, + {"summary": "ok", "proposedActions": []}, + ] + ) + runner = PlanRunner( + settings=settings(), + planner_client=SimpleNamespace(create_plan=planner), # type: ignore[arg-type] + ) + request = PlanAgentRequest.model_validate( + { + "chatSessionId": "1", + "input": "organize", + "context": { + "rootFolderId": "root", + "selectedFileIds": [], + "selectedFolderIds": [], + "currentPath": "/My Files", + }, + } + ) + job = BackgroundJob( + job_id=331, + task_type="agent.plan", + status="running", + payload=request.model_dump(by_alias=True), + result={}, + requested_by=7, + scheduled_at=datetime.now(UTC), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + result = await runner.run(db=DummyDb(), job=job) # type: ignore[arg-type] + + assert result.summary == "ok" + assert planner.await_count == 2 + ask_mock.assert_awaited_once() + + @pytest.mark.asyncio async def test_plan_runner_uses_planner_returned_count_action_for_movie_question( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1068,6 +1145,7 @@ async def test_plan_runner_uses_planner_returned_count_action_for_movie_question ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "我上传了多少部电影?", "context": { "rootFolderId": "root", @@ -1102,7 +1180,7 @@ async def test_plan_runner_uses_planner_returned_count_action_for_movie_question async def test_plan_runner_uses_planner_returned_count_action_for_anime_question( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1128,6 +1206,7 @@ async def test_plan_runner_uses_planner_returned_count_action_for_anime_question ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "我上传了多少动漫?", "context": { "rootFolderId": "root", @@ -1163,7 +1242,7 @@ async def test_plan_runner_uses_planner_returned_count_action_for_anime_question async def test_plan_runner_delegates_count_question_with_search_term_to_planner( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1194,6 +1273,7 @@ async def test_plan_runner_delegates_count_question_with_search_term_to_planner( ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "我上传了几部银翼杀手?", "context": { "rootFolderId": "root", @@ -1231,7 +1311,7 @@ async def test_plan_runner_delegates_count_question_with_search_term_to_planner( async def test_plan_runner_blocks_write_tool_in_exploratory_loop_and_continues( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1273,6 +1353,7 @@ async def fake_create_plan(**kwargs): # noqa: ANN003 ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "整理文件", "context": { "rootFolderId": "root", @@ -1306,7 +1387,7 @@ async def fake_create_plan(**kwargs): # noqa: ANN003 async def test_plan_runner_passes_only_read_tools_to_planner_tool_use( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1328,6 +1409,7 @@ async def fake_create_plan(**kwargs): # noqa: ANN003 ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "整理当前文件夹", "context": { "rootFolderId": "root", @@ -1364,7 +1446,7 @@ async def fake_create_plan(**kwargs): # noqa: ANN003 async def test_plan_runner_uses_planner_returned_move_action_when_unique_matches( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1389,6 +1471,7 @@ async def test_plan_runner_uses_planner_returned_move_action_when_unique_matches ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "把银翼杀手电影放到银翼杀手文件夹下", "context": { "rootFolderId": "root", @@ -1425,7 +1508,7 @@ async def test_plan_runner_uses_planner_returned_move_action_when_unique_matches async def test_plan_runner_uses_planner_returned_create_then_move_when_target_missing( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1455,6 +1538,7 @@ async def test_plan_runner_uses_planner_returned_create_then_move_when_target_mi ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "把银翼杀手电影放到银翼杀手文件夹下", "context": { "rootFolderId": "root", @@ -1491,7 +1575,7 @@ async def test_plan_runner_uses_planner_returned_create_then_move_when_target_mi async def test_plan_runner_rewrites_write_summary_with_grounded_facts( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1526,6 +1610,7 @@ async def test_plan_runner_rewrites_write_summary_with_grounded_facts( ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "把银翼杀手两部,移到银翼杀手文件夹里", "context": { "rootFolderId": "root", @@ -1569,7 +1654,7 @@ def all(self) -> list[tuple[Any, Any]]: async def test_plan_runner_records_planning_evidence_from_read_tools( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1621,6 +1706,7 @@ async def _planner_with_read_tool(**kwargs): # noqa: ANN003 ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "找出银翼杀手视频文件", "context": { "rootFolderId": "root", @@ -1660,7 +1746,7 @@ async def _planner_with_read_tool(**kwargs): # noqa: ANN003 async def test_plan_runner_uses_planner_returned_read_only_candidates_when_ambiguous( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr(plan_module, "_candidate_skills", AsyncMock(return_value=[])) monkeypatch.setattr( plan_module, "_collect_context_metadata", @@ -1685,6 +1771,7 @@ async def test_plan_runner_uses_planner_returned_read_only_candidates_when_ambig ) request = PlanAgentRequest.model_validate( { + "chatSessionId": "1", "input": "把银翼杀手电影放到银翼杀手文件夹下", "context": { "rootFolderId": "root", @@ -1716,6 +1803,178 @@ async def test_plan_runner_uses_planner_returned_read_only_candidates_when_ambig assert "ambiguous" in result.summary +@pytest.mark.asyncio +async def test_plan_runner_injects_skill_menu_and_use_skill_tool( + monkeypatch: pytest.MonkeyPatch, +): + fake_candidate = SimpleNamespace( + skill_key="organizeByType", + name="Organize by Type", + description="Organize files by type", + triggers_text="organize", + tool_whitelist_json=["drive.listFolder", "drive.moveFile"], + plan_template_json={}, + search_text="organize", + ) + monkeypatch.setattr( + plan_module, "_candidate_skills", AsyncMock(return_value=[fake_candidate]) + ) + monkeypatch.setattr( + plan_module, + "_collect_context_metadata", + AsyncMock(return_value={"scope": "currentFolder", "rootFolderId": "root", "files": [], "folders": []}), + ) + monkeypatch.setattr(plan_module, "_upsert_agent_plan", AsyncMock(return_value=None)) + + captured: dict[str, Any] = {} + + async def fake_create_plan(**kwargs): # noqa: ANN003 + captured["tools"] = kwargs.get("tools") + captured["system_prompt"] = kwargs.get("system_prompt") + return {"summary": "ok", "proposedActions": []} + + runner = PlanRunner( + settings=settings(), + planner_client=SimpleNamespace(create_plan=fake_create_plan), # type: ignore[arg-type] + ) + request = PlanAgentRequest.model_validate( + { + "chatSessionId": "1", + "input": "organize my files", + "context": { + "rootFolderId": "root", + "selectedFileIds": [], + "selectedFolderIds": [], + "currentPath": "/My Files", + }, + "executionPolicy": "confirm", + } + ) + job = BackgroundJob( + job_id=360, + task_type="agent.plan", + status="running", + payload=request.model_dump(by_alias=True), + result={}, + requested_by=7, + scheduled_at=datetime.now(UTC), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + await runner.run(db=DummyDb(), job=job) # type: ignore[arg-type] + + tools = captured["tools"] + assert tools is not None + tool_names = [t["name"] for t in tools] + assert "agent_use_skill" in tool_names # provider name for agent.useSkill + system_prompt = captured["system_prompt"] + assert "Available skills" in system_prompt + assert "organizeByType" in system_prompt + + +@pytest.mark.asyncio +async def test_plan_runner_use_skill_narrows_tools_and_blocks_out_of_whitelist( + monkeypatch: pytest.MonkeyPatch, +): + """LLM calls agent.useSkill('organizeByType') then drive.deleteFile; the + latter must be blocked because it is not in the skill's whitelist.""" + fake_candidate = SimpleNamespace( + skill_key="organizeByType", + name="Organize by Type", + description="Organize files by type", + triggers_text="organize", + tool_whitelist_json=["drive.listFolder", "drive.createFolder", "drive.moveFile"], + plan_template_json={}, + search_text="organize", + ) + monkeypatch.setattr( + plan_module, "_candidate_skills", AsyncMock(return_value=[fake_candidate]) + ) + monkeypatch.setattr( + plan_module, + "_collect_context_metadata", + AsyncMock(return_value={"scope": "currentFolder", "rootFolderId": "root", "files": [], "folders": []}), + ) + monkeypatch.setattr(plan_module, "_upsert_agent_plan", AsyncMock(return_value=None)) + + # bind_skill_in_planner (in skill_tool.py) constructs AgentSkillRepository(db) + # internally and calls get_by_key — patch it there to return a skill with the + # same whitelist as the candidate. + fake_skill = SimpleNamespace( + skill_key="organizeByType", + name="Organize by Type", + description="Organize files by type", + triggers_text="organize", + tool_whitelist_json=["drive.listFolder", "drive.createFolder", "drive.moveFile"], + plan_template_json={}, + ) + fake_repo = SimpleNamespace(get_by_key=AsyncMock(return_value=fake_skill)) + import fileflash.agents.harness.skill_tool as skill_tool_module + monkeypatch.setattr(skill_tool_module, "AgentSkillRepository", lambda _db: fake_repo) + + captured: dict[str, Any] = {} + + async def fake_create_plan(**kwargs): # noqa: ANN003 + tool_executor = kwargs["tool_executor"] + # 1. LLM binds the skill via agent.useSkill. + bind_result = await tool_executor("agent.useSkill", {"skillKey": "organizeByType"}) + assert bind_result["bound"] is True + assert "drive.moveFile" in bind_result["allowedTools"] + # 2. LLM attempts a non-whitelisted tool — must be blocked. + delete_result = await tool_executor("drive.deleteFile", {"fileId": "1"}) + captured["delete_result"] = delete_result + return { + "summary": "organize by type", + "proposedActions": [ + { + "step": 1, + "tool": "drive.moveFile", + "input": {"fileId": "1", "targetFolderId": "2"}, + }, + ], + } + + runner = PlanRunner( + settings=settings(), + planner_client=SimpleNamespace(create_plan=fake_create_plan), # type: ignore[arg-type] + ) + request = PlanAgentRequest.model_validate( + { + "chatSessionId": "1", + "input": "organize my files by type", + "context": { + "rootFolderId": "root", + "selectedFileIds": [], + "selectedFolderIds": [], + "currentPath": "/My Files", + }, + "executionPolicy": "confirm", + } + ) + job = BackgroundJob( + job_id=361, + task_type="agent.plan", + status="running", + payload=request.model_dump(by_alias=True), + result={}, + requested_by=7, + scheduled_at=datetime.now(UTC), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + result = await runner.run(db=DummyDb(), job=job) # type: ignore[arg-type] + + delete_result = captured["delete_result"] + assert delete_result["_plannerBlocked"] is True + assert delete_result["_toolError"] is True + assert delete_result["tool"] == "drive.deleteFile" + assert "not permitted" in delete_result["message"].lower() or "skill" in delete_result["message"].lower() + assert result.chosen_skill is not None + assert result.chosen_skill.id == "organizeByType" + + def test_normalize_actions_rejects_symbolic_placeholder_target_folder(): with pytest.raises(ApiError) as exc: plan_module._normalize_actions( @@ -1832,16 +2091,6 @@ def test_execute_reference_resolution_rejects_symbolic_placeholder(): assert "$stepN.field" in exc.value.message -@pytest.mark.asyncio -async def test_policy_guard_blocks_delete_without_confirmation(): - decision = await PolicyGuard().evaluate_tool_call( - tool_name="drive.deleteFile", - high_risk_confirmed=False, - ) - assert decision.allowed is False - assert classify_tool_risk("drive.deleteFolder") == "high" - - @pytest.mark.asyncio async def test_tool_router_dispatches_move_file(): router = ToolRouter(db=DummyDb(), user_id=7) # type: ignore[arg-type] @@ -1972,6 +2221,7 @@ async def test_execute_runner_normalizes_tool_output_before_action_log(monkeypat task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "500", "planHash": "sha256:test", "approval": { @@ -2053,6 +2303,7 @@ async def test_execute_runner_propagates_answer_model_errors(monkeypatch: pytest task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "510", "planHash": "sha256:test", "approval": { @@ -2136,6 +2387,7 @@ async def test_execute_runner_returns_count_files_answer(monkeypatch: pytest.Mon task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "501", "planHash": "sha256:test", "approval": { @@ -2220,6 +2472,7 @@ async def test_execute_runner_returns_count_files_answer_with_search_term( task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "502", "planHash": "sha256:test", "approval": { @@ -2313,6 +2566,7 @@ async def test_execute_runner_returns_count_files_answer_with_names_when_asked( task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "504", "planHash": "sha256:test", "approval": { @@ -2404,6 +2658,7 @@ async def test_execute_runner_lists_archive_names_for_read_only_archive_question task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "503", "planHash": "sha256:test", "approval": { @@ -2518,6 +2773,7 @@ async def test_execute_runner_returns_search_files_candidate_answer( task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "505", "planHash": "sha256:test", "approval": { @@ -2629,6 +2885,7 @@ def _execute_job_for_controls() -> BackgroundJob: task_type="agent.execute", status="running", payload={ + "chatSessionId": "1", "planJobId": "500", "planHash": "sha256:test", "approval": { @@ -2767,6 +3024,229 @@ async def test_execute_runner_canceled_via_inbox_at_step_boundary( assert dropped == [1] +def _control_message( + *, + inbox_message_id: int, + kind: AgentInboxKind, + step: int, + reason: str | None = None, +) -> SimpleNamespace: + metadata: dict[str, Any] = {"step": step} + if reason is not None: + metadata["reason"] = reason + return SimpleNamespace( + inbox_message_id=inbox_message_id, + kind=kind, + payload_json={"metadata": metadata}, + ) + + +def _patch_execute_dependencies_with_actions( + monkeypatch: pytest.MonkeyPatch, + *, + actions: list[dict[str, Any]], + controls: list[list[SimpleNamespace]], + dropped: list[int], +) -> None: + monkeypatch.setattr( + execute_module, + "AgentPlanRepository", + lambda _db: SimpleNamespace( + get_for_execute_binding=AsyncMock( + return_value=SimpleNamespace(proposed_actions_json=actions) + ) + ), + ) + monkeypatch.setattr( + execute_module, + "AgentWorkSessionRepository", + lambda _db: SimpleNamespace( + create_for_job=AsyncMock(return_value=None), + close_session=AsyncMock(return_value=None), + ), + ) + monkeypatch.setattr( + execute_module, + "AgentActionLogRepository", + lambda _db: SimpleNamespace( + append_step=AsyncMock(return_value=None), + finish_step=AsyncMock(return_value=None), + ), + ) + monkeypatch.setattr( + execute_module, + "ToolRouter", + lambda **kwargs: SimpleNamespace( + dispatch=AsyncMock( + return_value={ + "totalItems": 1, + "category": "video", + "recursive": True, + "folderId": "1", + "byMimeType": {"video/mp4": 1}, + "sampleItems": [], + } + ) + ), + ) + + class FakeInboxRepository: + def __init__(self, _db) -> None: # noqa: ANN001 + return None + + async def list_pending_controls(self, *, job_id: int): # noqa: ARG002 + if controls: + return controls.pop(0) + return [] + + async def mark_dropped(self, *, inbox_message_id: int) -> None: + dropped.append(inbox_message_id) + + monkeypatch.setattr(execute_module, "AgentInboxMessageRepository", FakeInboxRepository) + + +@pytest.mark.asyncio +async def test_execute_control_deny_skips_matching_step(monkeypatch: pytest.MonkeyPatch): + actions = [ + { + "step": 1, + "tool": "drive.countFiles", + "input": {"folderId": "root", "recursive": True, "category": "video"}, + "sideEffect": "read", + "riskLevel": "low", + "requiresConfirmation": False, + }, + { + "step": 2, + "tool": "drive.countFiles", + "input": {"folderId": "root", "recursive": True, "category": "image"}, + "sideEffect": "read", + "riskLevel": "low", + "requiresConfirmation": False, + }, + ] + deny_ctrl = _control_message( + inbox_message_id=10, + kind=AgentInboxKind.CONTROL_DENY, + step=1, + reason="user changed mind", + ) + controls = [[deny_ctrl], []] + dropped: list[int] = [] + _patch_execute_dependencies_with_actions( + monkeypatch, actions=actions, controls=controls, dropped=dropped + ) + db = DummyDb() + db.refresh = AsyncMock() + + result = await ExecuteRunner( + event_bus=_CaptureBus(), + answer_client=SimpleNamespace(create_answer=AsyncMock(return_value="ok")), # type: ignore[arg-type] + ).run(db=db, job=_execute_job_for_controls()) # type: ignore[arg-type] + + assert result.applied_actions == 1 + assert result.skipped_actions == 1 + assert dropped == [10] + assert any("denied by user" in w and "Step 1" in w for w in result.warnings) + assert any("user changed mind" in w for w in result.warnings) + + +@pytest.mark.asyncio +async def test_execute_control_deny_does_not_affect_wrong_step(monkeypatch: pytest.MonkeyPatch): + deny_ctrl = _control_message( + inbox_message_id=11, + kind=AgentInboxKind.CONTROL_DENY, + step=2, + reason="not this step", + ) + controls = [[deny_ctrl]] + dropped: list[int] = [] + _patch_execute_dependencies(monkeypatch, controls=controls, dropped=dropped) + db = DummyDb() + db.refresh = AsyncMock() + + result = await ExecuteRunner( + event_bus=_CaptureBus(), + answer_client=SimpleNamespace(create_answer=AsyncMock(return_value="ok")), # type: ignore[arg-type] + ).run(db=db, job=_execute_job_for_controls()) # type: ignore[arg-type] + + assert result.applied_actions == 1 + assert result.skipped_actions == 0 + assert dropped == [] + assert not any("denied by user" in w for w in result.warnings) + + +@pytest.mark.asyncio +async def test_execute_control_approve_proceeds(monkeypatch: pytest.MonkeyPatch): + approve_ctrl = _control_message( + inbox_message_id=12, + kind=AgentInboxKind.CONTROL_APPROVE, + step=1, + ) + controls = [[approve_ctrl]] + dropped: list[int] = [] + _patch_execute_dependencies(monkeypatch, controls=controls, dropped=dropped) + db = DummyDb() + db.refresh = AsyncMock() + + result = await ExecuteRunner( + event_bus=_CaptureBus(), + answer_client=SimpleNamespace(create_answer=AsyncMock(return_value="ok")), # type: ignore[arg-type] + ).run(db=db, job=_execute_job_for_controls()) # type: ignore[arg-type] + + assert result.applied_actions == 1 + assert result.skipped_actions == 0 + assert dropped == [12] + assert not any("skipped by user" in w for w in result.warnings) + + +@pytest.mark.asyncio +async def test_execute_control_skip_step_matching(monkeypatch: pytest.MonkeyPatch): + actions = [ + { + "step": 1, + "tool": "drive.countFiles", + "input": {"folderId": "root", "recursive": True, "category": "video"}, + "sideEffect": "read", + "riskLevel": "low", + "requiresConfirmation": False, + }, + { + "step": 2, + "tool": "drive.countFiles", + "input": {"folderId": "root", "recursive": True, "category": "image"}, + "sideEffect": "read", + "riskLevel": "low", + "requiresConfirmation": False, + }, + ] + skip_ctrl = _control_message( + inbox_message_id=20, + kind=AgentInboxKind.CONTROL_SKIP, + step=2, + ) + # The control persists across both step boundaries (not dropped at step 1 + # because the step-matching guard skips it), then fires at step 2. + controls = [[skip_ctrl], [skip_ctrl]] + dropped: list[int] = [] + _patch_execute_dependencies_with_actions( + monkeypatch, actions=actions, controls=controls, dropped=dropped + ) + db = DummyDb() + db.refresh = AsyncMock() + + result = await ExecuteRunner( + event_bus=_CaptureBus(), + answer_client=SimpleNamespace(create_answer=AsyncMock(return_value="ok")), # type: ignore[arg-type] + ).run(db=db, job=_execute_job_for_controls()) # type: ignore[arg-type] + + assert result.applied_actions == 1 + assert result.skipped_actions == 1 + assert dropped == [20] + assert any("Step 2 skipped by user" in w for w in result.warnings) + assert not any("Step 1 skipped by user" in w for w in result.warnings) + + @pytest.mark.asyncio async def test_execute_runner_publish_state_ignores_event_bus_failures(): bus = SimpleNamespace(publish=AsyncMock(side_effect=RuntimeError("boom"))) @@ -2791,3 +3271,130 @@ async def test_execute_runner_publish_tool_ignores_event_bus_failures(): ) assert bus.publish.await_count == 1 + + +@pytest.mark.asyncio +async def test_execute_denies_readfile_when_data_policy_disables_content( + monkeypatch: pytest.MonkeyPatch, +): + started = datetime.now(UTC) + job = BackgroundJob( + job_id=630, + task_type="agent.execute", + status="running", + payload={ + "chatSessionId": "1", + "planJobId": "520", + "planHash": "sha256:test", + "approval": { + "confirmedBy": "7", + "confirmedAt": started.isoformat(), + "highRiskConfirmed": False, + }, + }, + result={}, + requested_by=7, + scheduled_at=started, + created_at=started, + updated_at=started, + ) + action = { + "step": 1, + "tool": "drive.readFile", + "input": {"fileId": "1", "maxBytes": 1024}, + "sideEffect": "read", + "riskLevel": "low", + "requiresConfirmation": False, + } + plan = SimpleNamespace( + proposed_actions_json=[action], + execution_policy="confirm", + data_policy_json={"allowFileContent": False, "maxReadBytes": 1024, "allowedMimeTypes": ["*/*"]}, + chosen_skill_id=None, + input_text="read the file", + context_json={}, + ) + db = DummyDb() + db.refresh = AsyncMock() + + monkeypatch.setattr( + execute_module, + "AgentPlanRepository", + lambda _db: SimpleNamespace( + get_for_execute_binding=AsyncMock(return_value=plan) + ), + ) + monkeypatch.setattr( + execute_module, + "AgentWorkSessionRepository", + lambda _db: SimpleNamespace( + create_for_job=AsyncMock(return_value=None), + close_session=AsyncMock(return_value=None), + ), + ) + captured_steps: list[dict[str, object]] = [] + + def _capture_append(**kwargs): # noqa: ANN003 + captured_steps.append({"phase": "append", **kwargs}) + return None + + def _capture_finish(**kwargs): # noqa: ANN003 + captured_steps.append({"phase": "finish", **kwargs}) + return None + + monkeypatch.setattr( + execute_module, + "AgentActionLogRepository", + lambda _db: SimpleNamespace( + append_step=AsyncMock(side_effect=_capture_append), + finish_step=AsyncMock(side_effect=_capture_finish), + ), + ) + monkeypatch.setattr( + execute_module, + "AgentSettingsRepository", + lambda _db: SimpleNamespace(get_by_user_id=AsyncMock(return_value=None)), + ) + monkeypatch.setattr( + execute_module, + "AgentSkillRepository", + lambda _db: SimpleNamespace(get_by_key=AsyncMock(return_value=None)), + ) + monkeypatch.setattr( + execute_module, + "ToolRouter", + lambda **kwargs: SimpleNamespace(dispatch=AsyncMock(return_value={"content": "secret"})), + ) + bus = _CaptureBus() + + result = await ExecuteRunner( + event_bus=bus, + answer_client=SimpleNamespace(create_answer=AsyncMock(return_value="ok")), # type: ignore[arg-type] + ).run(db=db, job=job) # type: ignore[arg-type] + + # Job did NOT fail: it completed with 0 applied and 1 skipped (denied) action. + assert result.applied_actions == 0 + assert result.skipped_actions == 1 + assert any("denied by policy" in w for w in result.warnings) + + # ActionLog: append_step + finish_step both called with status="denied". + finish_calls = [c for c in captured_steps if c["phase"] == "finish"] + assert len(finish_calls) == 1 + assert finish_calls[0]["status"] == "denied" + assert finish_calls[0]["step_no"] == 1 + append_calls = [c for c in captured_steps if c["phase"] == "append"] + assert len(append_calls) == 1 + assert append_calls[0]["status"] == "denied" + + # tool.failed event published with denied: True. + failed_events = [e for e in bus.events if e.event_type == "tool.failed"] + assert len(failed_events) == 1 + assert failed_events[0].payload["denied"] is True + assert failed_events[0].payload["tool"] == "drive.readFile" + assert "File content access disabled by dataPolicy." in failed_events[0].payload["reasons"] + + # The tool dispatch was never called (denied before dispatch). + # ToolRouter is constructed once; verify dispatch not awaited by checking no + # tool.succeeded event exists. + succeeded_events = [e for e in bus.events if e.event_type == "tool.succeeded"] + assert succeeded_events == [] diff --git a/app/tests/test_agent_read_file.py b/app/tests/test_agent_read_file.py new file mode 100644 index 0000000..2e105dc --- /dev/null +++ b/app/tests/test_agent_read_file.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from fileflash.agents.harness.router import ToolCall, ToolRouter +from fileflash.agents.harness.tool_registry import REGISTRY +from fileflash.core.errors import ApiError + + +def _ensure_registered(): + import fileflash.agents.tools # noqa: F401 triggers registration + + +_ensure_registered() + + +@pytest.mark.asyncio +async def test_read_file_text_content(tmp_path): + _ensure_registered() + spec = REGISTRY.get("drive.readFile") + assert spec is not None + + db = AsyncMock() + file_row = MagicMock() + file_row.file_id = 7 + file_row.owner_id = 1 + file_row.file_name = "notes.txt" + file_row.mime_type = "text/plain" + file_row.file_ext = ".txt" + file_row.file_size = 5 + file_row.storage_object_id = 3 + file_row.status = "active" + file_row.is_latest = True + db.scalar = AsyncMock(side_effect=[file_row, MagicMock(object_key="obj-key")]) + + storage = AsyncMock() + # iter_object_range yields bytes chunks + async def _chunks(*a, **kw): + for c in [b"hello"]: + yield c + storage.iter_object_range = _chunks + storage.stat_object = AsyncMock(return_value=MagicMock(size=5)) + + from fileflash.agents.harness.tool_registry import ToolContext + ctx = ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=storage) + output = await spec.handler(ctx, {"fileId": "7"}) + assert output["content"] == "hello" + assert output["mime"] == "text/plain" + assert output["bytesReturned"] == 5 + + +@pytest.mark.asyncio +async def test_read_file_binary_returns_no_raw_bytes(): + _ensure_registered() + spec = REGISTRY.get("drive.readFile") + db = AsyncMock() + file_row = MagicMock() + file_row.file_id = 8 + file_row.owner_id = 1 + file_row.file_name = "pic.png" + file_row.mime_type = "image/png" + file_row.file_ext = ".png" + file_row.file_size = 2048 + file_row.storage_object_id = 4 + file_row.status = "active" + file_row.is_latest = True + db.scalar = AsyncMock(side_effect=[file_row, MagicMock(object_key="obj-key")]) + + storage = AsyncMock() + storage.stat_object = AsyncMock(return_value=MagicMock(size=2048)) + + from fileflash.agents.harness.tool_registry import ToolContext + ctx = ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=storage) + output = await spec.handler(ctx, {"fileId": "8"}) + assert "content" not in output or output.get("content") is None + assert output["truncated"] is True + + +@pytest.mark.asyncio +async def test_read_file_other_user_returns_404(): + _ensure_registered() + spec = REGISTRY.get("drive.readFile") + db = AsyncMock() + db.scalar = AsyncMock(return_value=None) # not found / not owned + from fileflash.agents.harness.tool_registry import ToolContext + ctx = ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=AsyncMock()) + with pytest.raises(ApiError) as exc: + await spec.handler(ctx, {"fileId": "999"}) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_read_file_offset_beyond_size_returns_empty(): + _ensure_registered() + spec = REGISTRY.get("drive.readFile") + db = AsyncMock() + file_row = MagicMock() + file_row.file_id = 9 + file_row.owner_id = 1 + file_row.file_name = "small.txt" + file_row.mime_type = "text/plain" + file_row.file_ext = ".txt" + file_row.file_size = 5 + file_row.storage_object_id = 5 + file_row.status = "active" + file_row.is_latest = True + db.scalar = AsyncMock(side_effect=[file_row, MagicMock(object_key="obj-key")]) + + storage = AsyncMock() + storage.stat_object = AsyncMock(return_value=MagicMock(size=5)) + storage.iter_object_range = AsyncMock() + + from fileflash.agents.harness.tool_registry import ToolContext + ctx = ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=storage) + output = await spec.handler(ctx, {"fileId": "9", "offset": 100}) + assert output["content"] == "" + assert output["bytesReturned"] == 0 + assert output["truncated"] is False + storage.iter_object_range.assert_not_called() diff --git a/app/tests/test_agent_routes.py b/app/tests/test_agent_routes.py index 68d225e..e194c61 100644 --- a/app/tests/test_agent_routes.py +++ b/app/tests/test_agent_routes.py @@ -154,6 +154,7 @@ def test_plan_route_returns_response_shell(): response = _client().post( "/api/v1/agent/plan", json={ + "chatSessionId": "1", "input": "organize", "context": { "rootFolderId": "root", @@ -182,6 +183,7 @@ def test_execute_route_returns_response_shell(): response = _client().post( "/api/v1/agent/execute", json={ + "chatSessionId": "1", "planJobId": "10", "planHash": "sha256:test", "approval": { @@ -216,6 +218,7 @@ def test_post_message_control_pause_returns_response_shell(): assert response.status_code == 200 body = response.json() assert body["success"] is True + assert "current step finishes" in body["message"] assert body["data"]["kind"] == "control.pause" assert body["data"]["inboxMessageId"] == "1" assert db.messages[0].role == AgentInboxRole.USER @@ -229,6 +232,7 @@ def test_job_events_route_streams_tool_and_final_answer_events(): body = response.text assert "event: tool.started" in body assert "event: tool.succeeded" in body + assert "event: agent.progress" in body assert "event: job.succeeded" in body assert "正在读取名称包含" in body assert "银翼杀手" in body @@ -282,3 +286,25 @@ async def subscribe(self, *, job_id: int): # noqa: ARG002 assert response.status_code == 200 assert "event: agent.progress" in response.text assert "event: job.succeeded" in response.text + + +def test_plan_request_with_data_policy_flows_through(): + response = _client().post( + "/api/v1/agent/plan", + json={ + "chatSessionId": "1", + "input": "list my files", + "context": {"rootFolderId": "root"}, + "dataPolicy": { + "allowFileContent": True, + "maxReadBytes": 512000, + "allowedMimeTypes": ["text/*"], + }, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["data"]["jobId"] == "10" + assert body["data"]["taskType"] == "agent.plan" diff --git a/app/tests/test_agent_use_skill.py b/app/tests/test_agent_use_skill.py new file mode 100644 index 0000000..1a45df2 --- /dev/null +++ b/app/tests/test_agent_use_skill.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from fileflash.agents.harness.permission import PermissionResolver +from fileflash.agents.harness.skill_tool import bind_skill_in_planner +from fileflash.agents.harness.tool_registry import REGISTRY +from fileflash.schemas.agent import PlanAgentRequest + + +def _request() -> PlanAgentRequest: + return PlanAgentRequest.model_validate( + {"chatSessionId": "1", "input": "organize my downloads", "context": {"rootFolderId": "root"}} + ) + + +@pytest.mark.asyncio +async def test_useskill_registers_in_registry_for_llm(): + import fileflash.agents.tools # noqa: F401 + spec = REGISTRY.get("agent.useSkill") + assert spec is not None + assert spec.side_effect == "read" + + +@pytest.mark.asyncio +async def test_bind_skill_narrows_allowed_tools(monkeypatch): + skill = MagicMock() + skill.skill_key = "organizeByType" + skill.tool_whitelist_json = ["drive.listFolder", "drive.createFolder", "drive.moveFile"] + skill.name = "organizeByType" + skill.description = "" + skill.triggers_text = "" + skill.plan_template_json = {} + skill.search_text = "" + + repo = MagicMock() + repo.get_by_key = AsyncMock(return_value=skill) + + db = AsyncMock() + # bind_skill_in_planner lives in skill_tool.py and constructs AgentSkillRepository(db) + # there, so the patch target must be that module — not plan_runner. + import fileflash.agents.harness.skill_tool as skill_tool_module + monkeypatch.setattr(skill_tool_module, "AgentSkillRepository", lambda d: repo) + + base_perm = await PermissionResolver().effective( + request=_request(), setting=None, skill=None, high_risk_confirmed=False + ) + new_perm, payload = await bind_skill_in_planner( + db=db, + user_id=1, + skill_key="organizeByType", + candidates=[skill], + request=_request(), + setting=None, + current_permission=base_perm, + ) + assert payload["bound"] is True + assert "drive.deleteFile" not in new_perm.allowed_tools + assert "drive.moveFile" in new_perm.allowed_tools + assert new_perm.skill_key == "organizeByType" + + +@pytest.mark.asyncio +async def test_bind_skill_unknown_key_returns_error(monkeypatch): + repo = MagicMock() + repo.get_by_key = AsyncMock(return_value=None) + db = AsyncMock() + import fileflash.agents.harness.skill_tool as skill_tool_module + monkeypatch.setattr(skill_tool_module, "AgentSkillRepository", lambda d: repo) + base_perm = await PermissionResolver().effective( + request=_request(), setting=None, skill=None, high_risk_confirmed=False + ) + _, payload = await bind_skill_in_planner( + db=db, + user_id=1, + skill_key="nope", + candidates=[], + request=_request(), + setting=None, + current_permission=base_perm, + ) + assert payload["bound"] is False + assert "unknown" in payload["message"].lower() or "not found" in payload["message"].lower() diff --git a/docker/flyway/migrations/V16__agent_chat_sessions.sql b/docker/flyway/migrations/V16__agent_chat_sessions.sql new file mode 100644 index 0000000..a808d7b --- /dev/null +++ b/docker/flyway/migrations/V16__agent_chat_sessions.sql @@ -0,0 +1,61 @@ +-- ========================= +-- Domain: agent chat sessions +-- ========================= + +CREATE TABLE IF NOT EXISTS agent_chat_session ( + chat_session_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + archived BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_agent_chat_session_user + FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_agent_chat_session_user_deleted_updated + ON agent_chat_session (user_id, deleted_at, updated_at DESC); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'background_job' + AND column_name = 'chat_session_id' + ) THEN + ALTER TABLE background_job ADD COLUMN chat_session_id BIGINT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'background_job' + AND column_name = 'deleted_at' + ) THEN + ALTER TABLE background_job ADD COLUMN deleted_at TIMESTAMP NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_name = 'background_job' + AND constraint_name = 'fk_background_job_agent_chat_session' + ) THEN + ALTER TABLE background_job + ADD CONSTRAINT fk_background_job_agent_chat_session + FOREIGN KEY (chat_session_id) + REFERENCES agent_chat_session(chat_session_id) + ON DELETE CASCADE; + END IF; +END +$$; + +CREATE INDEX IF NOT EXISTS idx_background_job_chat_session_created + ON background_job (chat_session_id, created_at) + WHERE chat_session_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_background_job_chat_session_status + ON background_job (chat_session_id, status) + WHERE chat_session_id IS NOT NULL AND deleted_at IS NULL; diff --git a/docker/flyway/migrations/V17__agent_builtin_skills_extra.sql b/docker/flyway/migrations/V17__agent_builtin_skills_extra.sql new file mode 100644 index 0000000..f3cc597 --- /dev/null +++ b/docker/flyway/migrations/V17__agent_builtin_skills_extra.sql @@ -0,0 +1,47 @@ +INSERT INTO agent_skill ( + skill_key, + name, + description, + triggers_text, + tool_whitelist_json, + plan_template_json, + inputs_schema_json, + outputs_schema_json, + visibility, + owner_user_id +) +VALUES +( + 'builtin:dedupScan', + 'Dedup Scan', + 'Find duplicate files by content hash or name+size and propose deletion of duplicates.', + 'duplicate files, dedup, find duplicates, 重复文件, 去重', + '["drive.listFolder","drive.getFileInfo","drive.findDuplicates","drive.deleteFile"]'::jsonb, + '{"strategy":"Group duplicates by content hash; propose keeping the oldest and deleting the rest. Deletions are high risk and require explicit confirmation."}'::jsonb, + '{"type":"object","required":["sourceFolderId"],"properties":{"sourceFolderId":{"type":"string"},"by":{"type":"string","enum":["hash","nameSize"],"default":"hash"}}}'::jsonb, + '{"type":"object"}'::jsonb, + 'global', + NULL +), +( + 'builtin:listAndSummarize', + 'List And Summarize', + 'List the contents of a folder and produce a statistical summary by category.', + 'list files, summarize, folder summary, statistics, 列出文件, 统计摘要', + '["drive.listFolder","drive.countFiles","drive.getFileInfo","drive.statsByCategory"]'::jsonb, + '{"strategy":"List direct children, then compute counts and sizes by category. Read-only; no writes."}'::jsonb, + '{"type":"object","required":["sourceFolderId"],"properties":{"sourceFolderId":{"type":"string"}}}'::jsonb, + '{"type":"object"}'::jsonb, + 'global', + NULL +) +ON CONFLICT (skill_key) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + triggers_text = EXCLUDED.triggers_text, + tool_whitelist_json = EXCLUDED.tool_whitelist_json, + plan_template_json = EXCLUDED.plan_template_json, + inputs_schema_json = EXCLUDED.inputs_schema_json, + outputs_schema_json = EXCLUDED.outputs_schema_json, + visibility = EXCLUDED.visibility, + owner_user_id = EXCLUDED.owner_user_id; diff --git a/docs/superpowers/plans/2026-07-09-agent-permission-skills.md b/docs/superpowers/plans/2026-07-09-agent-permission-skills.md new file mode 100644 index 0000000..17986fc --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-agent-permission-skills.md @@ -0,0 +1,1837 @@ +# Agent Permission Layer + LLM-Invocable Skills — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the agent's tool-calling permission-gated and skill-aware by widening `PolicyGuard` into a single choke point (executionPolicy + dataPolicy + skill-whitelist intersection, 取最严), adding `drive.readFile` with enforced dataPolicy, and making skills LLM-selectable via an `agent.useSkill` meta-tool. + +**Architecture:** One enforcement point — `PolicyGuard.evaluate(ctx, action, permission, phase)` — fed by a pure `PermissionResolver` that merges request policy + `AgentUserSetting` defaults + skill whitelist into a frozen `EffectivePermission`. Skills become an LLM-invocable surface: top-K candidates injected into the system prompt; the LLM calls `agent.useSkill` (intercepted in the planner, never reaching `ToolRouter`) to bind a skill and narrow `allowed_tools`. `readFile` is a normal tool whose content access is gated by dataPolicy inside the same guard. + +**Tech Stack:** Python 3.12, FastAPI, async SQLAlchemy 2.x, Pydantic v2 (`CamelModel`), Redis Streams, Anthropic SDK, MinIO (`MinioObjectStorageClient`), pytest (real DB + real services, `AsyncMock` for LLM only). + +## Global Constraints + +- **Tests run with real Postgres + real services** (project convention — see existing `test_agent_plan_execute_runtime.py`); only the LLM client (`AnthropicPlannerClient`) is mocked via `AsyncMock`. +- **Test command:** `cd app && python -m pytest tests/test_agent_plan_execute_runtime.py tests/test_agent_routes.py -v` (run from `app/` dir; project uses `bun` only for `web/`, Python tests use `pytest`). +- **CamelCase API:** all request/response schemas extend `CamelModel` and use camelCase aliases (`allow_file_content` ↔ `allowFileContent`). Existing pattern in [schemas/agent.py](../../app/src/fileflash/schemas/agent.py). +- **Ownership scoping stays in handlers** (`File.owner_id == ctx.user_id`); the permission layer is additive. 404 (never 403) for missing/non-owned resources — no existence leak. +- **`SettingsService` is a NotImplementedError scaffold** — do NOT use it. Use `AgentSettingsRepository(db).get_by_user_id(user_id)` directly (verified working at [repositories/agent/settings.py:15](../../app/src/fileflash/repositories/agent/settings.py)). +- **No new tables, zero schema migration.** `AgentActionLog.status` is `String(20)` free text ([tables_agent.py:296](../../app/src/fileflash/models/tables_agent.py)) — new value `"denied"` needs no migration. Builtin skills seeded as DB rows via one seed migration. +- **Ownership of `PolicyGuard`/`classify_tool_*`:** the existing functions `classify_tool_risk`, `classify_tool_side_effect`, `normalize_action_risk` are imported by tests and `plan_runner` — keep them (they back `EffectivePermission` computation); only `PolicyGuard.evaluate_tool_call` is replaced by `evaluate`. +- **Commits after every task** (TDD red-green-commit). Conventional-commit messages, scope `agent`. +- **`max 1 subagent`** dispatch rule is satisfied — this plan is executed inline or via subagent-driven-development one task at a time; no parallel fan-out required. + +--- + +## File Structure + +**Create:** +- `app/src/fileflash/agents/harness/permission.py` — `EffectivePermission` dataclass + `PermissionResolver` (merges request + setting + skill → effective permission, 取最严). +- `app/src/fileflash/agents/harness/skill_tool.py` — `agent.useSkill` ToolSpec registration + `_bind_skill` interceptor (called from planner, mutates `EffectivePermission`). +- `app/tests/test_agent_permission.py` — unit tests for `PermissionResolver` + `PolicyGuard.evaluate`. +- `app/tests/test_agent_read_file.py` — integration tests for `drive.readFile` handler + dataPolicy gating. +- `app/tests/test_agent_use_skill.py` — integration tests for the `useSkill` interception flow. +- `docker/flyway/migrations/V17__agent_builtin_skills_extra.sql` — seed 2 additional builtin skill rows (dedupScan, listAndSummarize); organizeByType already exists via V13/V15. + +**Modify:** +- `app/src/fileflash/agents/harness/tool_registry.py` — add `storage_reader` to `ToolContext`. +- `app/src/fileflash/agents/harness/policy.py` — rewrite `PolicyGuard`: new async `evaluate(ctx, action, permission, phase)`; keep `classify_tool_*`. +- `app/src/fileflash/agents/harness/router.py` — `ToolRouter` constructs `storage_reader` from settings; passes it into `ToolContext`. +- `app/src/fileflash/agents/tools/drive.py` — add `_read_file` handler + `drive.readFile` registration. +- `app/src/fileflash/agents/runtime/plan_runner.py` — `_choose_skill`→`_candidate_skills` (top-K); delegate tool gating to `PolicyGuard`; intercept `agent.useSkill`; apply `AgentUserSetting` defaults. +- `app/src/fileflash/agents/runtime/execute_runner.py` — per-step `PolicyGuard.evaluate(phase="executing")`; write `denied` ActionLog + `tool.failed`. +- `app/src/fileflash/core/settings.py` — add `agent_read_file_max_bytes`, `agent_skill_candidate_k`. +- `app/tests/test_agent_plan_execute_runtime.py` — extend with readFile/dataPolicy/useSkill e2e cases. +- `app/tests/test_agent_routes.py` — extend with dataPolicy flow + setting override. + +--- + +## Task 1: `EffectivePermission` + `PermissionResolver` + +**Files:** +- Create: `app/src/fileflash/agents/harness/permission.py` +- Test: `app/tests/test_agent_permission.py` + +**Interfaces:** +- Produces: `EffectivePermission` (frozen dataclass), `PermissionResolver` with `async effective(*, request, setting, skill, high_risk_confirmed) -> EffectivePermission`. +- Consumes: `PlanAgentRequest`, `AgentDataPolicy` (from [schemas/agent.py](../../app/src/fileflash/schemas/agent.py)), `AgentUserSetting` (from models), `REGISTRY`, existing `_skill_tool_whitelist` logic (moved here). + +- [ ] **Step 1: Write the failing tests** + +Create `app/tests/test_agent_permission.py`: + +```python +from __future__ import annotations + +import pytest + +from fileflash.agents.harness.permission import EffectivePermission, PermissionResolver +from fileflash.models import AgentUserSetting +from fileflash.schemas.agent import AgentDataPolicy, PlanAgentRequest + + +def _request(**overrides) -> PlanAgentRequest: + base = { + "chatSessionId": "1", + "input": "list my files", + "context": {"rootFolderId": "root"}, + } + base.update(overrides) + return PlanAgentRequest.model_validate(base) + + +@pytest.mark.asyncio +async def test_effective_defaults_when_no_setting_no_skill(): + resolver = PermissionResolver() + perm = await resolver.effective( + request=_request(), + setting=None, + skill=None, + high_risk_confirmed=False, + ) + assert perm.execution_policy == "confirm" + assert perm.deny_read_content is True # default allow_file_content=False + assert perm.skill_key is None + assert "drive.listFolder" in perm.allowed_tools + + +@pytest.mark.asyncio +async def test_effective_setting_overrides_data_policy_take_strictest(): + # Setting says allow_file_content=True; request says False -> False wins (取最严) + setting = AgentUserSetting( + user_id=1, + default_execution_policy="confirm", + default_data_policy_json={"allowFileContent": True, "maxReadBytes": 2097152}, + ) + perm = await resolver.effective( + request=_request(dataPolicy={"allowFileContent": False}), + setting=setting, + skill=None, + high_risk_confirmed=False, + ) + assert perm.deny_read_content is True + + +@pytest.mark.asyncio +async def test_effective_setting_denies_content_even_when_request_allows(): + # Setting allow_file_content=False; request True -> False wins + setting = AgentUserSetting( + user_id=1, + default_data_policy_json={"allowFileContent": False, "maxReadBytes": 0}, + ) + perm = await PermissionResolver().effective( + request=_request(dataPolicy={"allowFileContent": True}), + setting=setting, + skill=None, + high_risk_confirmed=False, + ) + assert perm.deny_read_content is True + assert perm.data_policy.max_read_bytes == 0 + + +@pytest.mark.asyncio +async def test_effective_mime_intersection_empty_denies_content(): + setting = AgentUserSetting( + user_id=1, + default_data_policy_json={"allowedMimeTypes": ["image/*"]}, + ) + perm = await PermissionResolver().effective( + request=_request(dataPolicy={"allowedMimeTypes": ["text/*"]}), + setting=setting, + skill=None, + high_risk_confirmed=False, + ) + assert perm.data_policy.allowed_mime_types == [] + assert perm.deny_read_content is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd app && python -m pytest tests/test_agent_permission.py -v` +Expected: FAIL with `ModuleNotFoundError: fileflash.agents.harness.permission` + +- [ ] **Step 3: Write `permission.py`** + +Create `app/src/fileflash/agents/harness/permission.py`: + +```python +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ...models import AgentUserSetting +from ...schemas.agent import AgentDataPolicy, AgentExecutionPolicy, PlanAgentRequest +from .tool_registry import REGISTRY + + +@dataclass(frozen=True, slots=True) +class EffectivePermission: + execution_policy: AgentExecutionPolicy + data_policy: AgentDataPolicy + allowed_tools: frozenset[str] + skill_key: str | None + deny_read_content: bool + high_risk_confirmed: bool + + +class PermissionResolver: + async def effective( + self, + *, + request: PlanAgentRequest, + setting: AgentUserSetting | None, + skill: Any, + high_risk_confirmed: bool, + ) -> EffectivePermission: + execution_policy = request.execution_policy + data_policy = _merge_data_policy(request.data_policy, setting) + skill_whitelist = _skill_tool_whitelist(skill) + allowed_tools = frozenset(skill_whitelist) + skill_key = _skill_key(skill) + deny_read_content = ( + not data_policy.allow_file_content + or not data_policy.allowed_mime_types + ) + return EffectivePermission( + execution_policy=execution_policy, + data_policy=data_policy, + allowed_tools=allowed_tools, + skill_key=skill_key, + deny_read_content=deny_read_content, + high_risk_confirmed=high_risk_confirmed, + ) + + +def _merge_data_policy( + request_policy: AgentDataPolicy, setting: AgentUserSetting | None +) -> AgentDataPolicy: + if setting is None: + return request_policy + setting_policy = _setting_data_policy(setting) + allow = request_policy.allow_file_content and setting_policy.allow_file_content + max_bytes = min(request_policy.max_read_bytes, setting_policy.max_read_bytes) + allowed_mimes = _intersect_mime_globs( + request_policy.allowed_mime_types, setting_policy.allowed_mime_types + ) + return AgentDataPolicy( + allow_file_content=allow, + max_read_bytes=max_bytes, + allowed_mime_types=allowed_mimes, + ) + + +def _setting_data_policy(setting: AgentUserSetting) -> AgentDataPolicy: + raw = setting.default_data_policy_json or {} + if not isinstance(raw, dict): + raw = {} + return AgentDataPolicy.model_validate(raw) + + +def _intersect_mime_globs(a: list[str], b: list[str]) -> list[str]: + # ["*/*"] means "all"; intersection with X = X. + if "*/*" in a and "*/*" in b: + return ["*/*"] + if "*/*" in a: + return list(b) + if "*/*" in b: + return list(a) + return [m for m in a if m in b] + + +def _skill_tool_whitelist(skill: Any) -> tuple[str, ...]: + if skill is None: + return REGISTRY.all_names() + raw = getattr(skill, "tool_whitelist_json", None) + if isinstance(raw, list) and raw: + tools = tuple(str(item) for item in raw if str(item).strip()) + unknown = REGISTRY.unknown_names(tools) + if unknown: + from ...core.errors import ApiError + raise ApiError( + status_code=422, + code=422, + message="Unknown agent tool in selected skill", + data={"unknownTools": sorted(unknown)}, + ) + return tools + return REGISTRY.all_names() + + +def _skill_key(skill: Any) -> str | None: + if skill is None: + return None + return str(getattr(skill, "skill_key", None) or "") + + +__all__ = ["EffectivePermission", "PermissionResolver"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && python -m pytest tests/test_agent_permission.py -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/fileflash/agents/harness/permission.py app/tests/test_agent_permission.py +git commit -m "feat(agent): add PermissionResolver and EffectivePermission" +``` + +--- + +## Task 2: Rewrite `PolicyGuard.evaluate` as the single choke point + +**Files:** +- Modify: `app/src/fileflash/agents/harness/policy.py` +- Test: `app/tests/test_agent_permission.py` (extend) + +**Interfaces:** +- Produces: `async PolicyGuard.evaluate(*, ctx: ToolContext, action: AgentProposedAction, permission: EffectivePermission, phase: Literal["planning","executing"]) -> PolicyDecision`. +- Consumes: `EffectivePermission` (Task 1), `ToolContext`, `AgentProposedAction`, `REGISTRY`. +- Keeps: `classify_tool_risk`, `classify_tool_side_effect`, `normalize_action_risk`, `PolicyDecision`. + +- [ ] **Step 1: Add failing tests for `PolicyGuard.evaluate`** + +Append to `app/tests/test_agent_permission.py`: + +```python +from datetime import datetime +from unittest.mock import AsyncMock + +from fileflash.agents.harness.policy import PolicyGuard, PolicyDecision +from fileflash.agents.harness.tool_registry import ToolContext +from fileflash.schemas.agent import AgentProposedAction + + +def _ctx_with_mime(mime: str = "text/plain") -> ToolContext: + db = AsyncMock() + db.scalar = AsyncMock(return_value=type("F", (), {"mime_type": mime, "file_ext": None, "file_name": "x.txt"})()) + return ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=None) + + +def _perm(*, allowed_tools=None, deny_read=False, allow_content=True, mimes=None, high_risk=False, policy="confirm"): + from fileflash.agents.harness.permission import EffectivePermission + from fileflash.schemas.agent import AgentDataPolicy + return EffectivePermission( + execution_policy=policy, + data_policy=AgentDataPolicy( + allow_file_content=allow_content, + max_read_bytes=1048576, + allowed_mime_types=mimes if mimes is not None else ["*/*"], + ), + allowed_tools=frozenset(allowed_tools) if allowed_tools else frozenset(REGISTRY_NAMES), + skill_key=None, + deny_read_content=deny_read, + high_risk_confirmed=high_risk, + ) + + +REGISTRY_NAMES = __import__("fileflash.agents.harness.tool_registry", fromlist=["REGISTRY"]).REGISTRY.all_names() + + +@pytest.mark.asyncio +async def test_evaluate_unknown_tool_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.noSuch", input={}, side_effect="read"), + permission=_perm(), + phase="executing", + ) + assert decision.allowed is False + assert any("unknown" in r.lower() or "unsupported" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_tool_not_in_whitelist_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.deleteFile", input={"fileId": "1"}, side_effect="write"), + permission=_perm(allowed_tools=["drive.listFolder"]), + phase="executing", + ) + assert decision.allowed is False + assert any("skill" in r.lower() or "permitted" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_readfile_blocked_when_content_disabled(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.readFile", input={"fileId": "1"}, side_effect="read"), + permission=_perm(deny_read=True, allowed_tools=["drive.readFile"]), + phase="executing", + ) + assert decision.allowed is False + assert any("content" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_readfile_mime_not_allowed_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(mime="application/pdf"), + action=AgentProposedAction(step=1, tool="drive.readFile", input={"fileId": "1"}, side_effect="read"), + permission=_perm(allowed_tools=["drive.readFile"], mimes=["text/*"]), + phase="executing", + ) + assert decision.allowed is False + assert any("mime" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_high_risk_without_confirmation_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.deleteFile", input={"fileId": "1"}, side_effect="write", risk_level="high"), + permission=_perm(allowed_tools=["drive.deleteFile"], high_risk=False), + phase="executing", + ) + assert decision.allowed is False + assert any("confirmation" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_planonly_executing_denied(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.createFolder", input={"name": "x"}, side_effect="write", risk_level="medium"), + permission=_perm(allowed_tools=["drive.createFolder"], policy="planOnly"), + phase="executing", + ) + assert decision.allowed is False + assert any("planonly" in r.lower() for r in decision.reasons) + + +@pytest.mark.asyncio +async def test_evaluate_allowed_read_tool_passes(): + decision = await PolicyGuard().evaluate( + ctx=_ctx_with_mime(), + action=AgentProposedAction(step=1, tool="drive.listFolder", input={"folderId": "root"}, side_effect="read"), + permission=_perm(allowed_tools=["drive.listFolder"]), + phase="executing", + ) + assert decision.allowed is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd app && python -m pytest tests/test_agent_permission.py -v -k evaluate` +Expected: FAIL — `evaluate` not defined / `ToolContext` missing `storage_reader` / import errors. + +- [ ] **Step 3: Add `storage_reader` to `ToolContext`** + +In `app/src/fileflash/agents/harness/tool_registry.py`, change the `ToolContext` dataclass (line ~14): + +```python +@dataclass(slots=True) +class ToolContext: + db: AsyncSession + user_id: int + file_service: Any + folder_service: Any + storage_reader: Any = None +``` + +(`Any = None` keeps existing `ToolContext(...)` call sites that don't pass it from breaking during transition.) + +- [ ] **Step 4: Rewrite `PolicyGuard` in `policy.py`** + +Replace the entire contents of `app/src/fileflash/agents/harness/policy.py` with the following (this keeps `PolicyDecision`, `classify_tool_risk`, `classify_tool_side_effect`, `normalize_action_risk` and replaces the old `PolicyGuard.evaluate_tool_call` with the new async `evaluate`): + +```python +from __future__ import annotations + +import fnmatch +from dataclasses import dataclass, field +from typing import Any, Literal + +from sqlalchemy import and_, select + +from ...core.errors import ApiError +from ...core.mime import resolve_file_mime_type +from ...models import File +from ...models.enums import FileStatus +from ...schemas.agent import AgentProposedAction +from .permission import EffectivePermission +from .tool_registry import REGISTRY, ToolContext + +_CONTENT_READ_TOOLS = frozenset({"drive.readFile"}) +_Phase = Literal["planning", "executing"] + + +@dataclass(slots=True) +class PolicyDecision: + allowed: bool + reasons: list[str] = field(default_factory=list) + + +def classify_tool_side_effect(tool_name: str) -> str: + try: + return REGISTRY.get(tool_name).side_effect + except KeyError: + return "write" + + +def classify_tool_risk(tool_name: str) -> str: + try: + return REGISTRY.get(tool_name).risk_level + except KeyError: + return "high" + + +def normalize_action_risk(action: AgentProposedAction) -> AgentProposedAction: + risk_level = classify_tool_risk(action.tool) + requires_confirmation = action.requires_confirmation or risk_level == "high" + reason = action.confirmation_reason + if risk_level == "high" and not reason: + reason = ( + "Deleting files or folders is a high-risk action and requires explicit confirmation." + ) + return action.model_copy( + update={ + "side_effect": classify_tool_side_effect(action.tool), + "risk_level": risk_level, + "requires_confirmation": requires_confirmation, + "confirmation_reason": reason, + } + ) + + +class PolicyGuard: + async def evaluate( + self, + *, + ctx: ToolContext, + action: AgentProposedAction, + permission: EffectivePermission, + phase: _Phase, + ) -> PolicyDecision: + try: + spec = REGISTRY.get(action.tool) + except KeyError: + return PolicyDecision( + allowed=False, + reasons=[f"Unsupported agent tool: {action.tool}"], + ) + if action.tool not in permission.allowed_tools: + return PolicyDecision( + allowed=False, + reasons=[f"Tool not permitted by active skill/policy: {action.tool}"], + ) + if spec.side_effect == "read" and action.tool in _CONTENT_READ_TOOLS: + decision = await self._check_content_read( + ctx=ctx, action=action, permission=permission + ) + if decision is not None: + return decision + if spec.risk_level == "high" and not permission.high_risk_confirmed: + return PolicyDecision( + allowed=False, + reasons=["High-risk action requires explicit confirmation."], + ) + if permission.execution_policy == "planOnly" and phase == "executing": + return PolicyDecision( + allowed=False, + reasons=["planOnly policy forbids execution."], + ) + return PolicyDecision(allowed=True) + + async def _check_content_read( + self, + *, + ctx: ToolContext, + action: AgentProposedAction, + permission: EffectivePermission, + ) -> PolicyDecision | None: + if permission.deny_read_content: + return PolicyDecision( + allowed=False, + reasons=["File content access disabled by dataPolicy."], + ) + max_bytes = self._byte_range(action.input) + if max_bytes > permission.data_policy.max_read_bytes: + return PolicyDecision( + allowed=False, + reasons=[ + f"Requested bytes ({max_bytes}) exceed max_read_bytes " + f"({permission.data_policy.max_read_bytes})." + ], + ) + mime = await _resolve_target_mime(ctx=ctx, action=action) + if mime is not None and not _mime_allowed( + mime, permission.data_policy.allowed_mime_types + ): + return PolicyDecision( + allowed=False, + reasons=[f"File mime '{mime}' not in allowed_mime_types."], + ) + return None + + def _byte_range(self, action_input: dict[str, Any]) -> int: + max_bytes = int(action_input.get("maxBytes", 262144) or 262144) + offset = int(action_input.get("offset", 0) or 0) + return max_bytes + offset + + +def _mime_allowed(mime: str, allowed: list[str]) -> bool: + lowered = mime.lower() + return any(fnmatch.fnmatch(lowered, pattern.lower()) for pattern in allowed) + + +async def _resolve_target_mime( + *, ctx: ToolContext, action: AgentProposedAction +) -> str | None: + file_id = action.input.get("fileId") or action.input.get("id") + if file_id is None: + return None + try: + parsed = int(str(file_id)) + except (TypeError, ValueError): + return None + row = await ctx.db.scalar( + select(File).where( + and_( + File.file_id == parsed, + File.owner_id == ctx.user_id, + File.status == FileStatus.ACTIVE, + ) + ) + ) + if row is None: + return None + return resolve_file_mime_type( + mime_type=row.mime_type, + file_ext=row.file_ext, + file_name=row.file_name, + ) + + +__all__ = [ + "PolicyDecision", + "PolicyGuard", + "classify_tool_risk", + "classify_tool_side_effect", + "normalize_action_risk", +] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd app && python -m pytest tests/test_agent_permission.py -v` +Expected: PASS (all 11 tests — 4 resolver + 7 evaluate) + +- [ ] **Step 6: Commit** + +```bash +git add app/src/fileflash/agents/harness/policy.py app/src/fileflash/agents/harness/tool_registry.py app/tests/test_agent_permission.py +git commit -m "feat(agent): widen PolicyGuard.evaluate to single permission choke point" +``` + +--- + +## Task 3: `drive.readFile` tool + `storage_reader` wiring + +**Files:** +- Modify: `app/src/fileflash/agents/tools/drive.py` +- Modify: `app/src/fileflash/agents/harness/router.py` +- Test: `app/tests/test_agent_read_file.py` + +**Interfaces:** +- Produces: `drive.readFile` ToolSpec (registered into `REGISTRY`); `ToolRouter` now builds `MinioObjectStorageClient` and passes it into `ToolContext.storage_reader`. +- Consumes: `MinioObjectStorageClient.iter_object_range` / `stat_object` (from [s3/minio_client.py](../../app/src/fileflash/s3/minio_client.py)); `StorageObject`, `File` models. + +- [ ] **Step 1: Write the failing tests** + +Create `app/tests/test_agent_read_file.py`: + +```python +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from fileflash.agents.harness.router import ToolCall, ToolRouter +from fileflash.agents.harness.tool_registry import REGISTRY +from fileflash.core.errors import ApiError + + +def _ensure_registered(): + import fileflash.agents.tools # noqa: F401 triggers registration + + +_ensure_registered() + + +@pytest.mark.asyncio +async def test_read_file_text_content(tmp_path): + _ensure_registered() + spec = REGISTRY.get("drive.readFile") + assert spec is not None + + db = AsyncMock() + file_row = MagicMock() + file_row.file_id = 7 + file_row.owner_id = 1 + file_row.file_name = "notes.txt" + file_row.mime_type = "text/plain" + file_row.file_ext = ".txt" + file_row.file_size = 5 + file_row.storage_object_id = 3 + file_row.status = "active" + file_row.is_latest = True + db.scalar = AsyncMock(side_effect=[file_row, MagicMock(object_key="obj-key")]) + + storage = AsyncMock() + # iter_object_range yields bytes chunks + async def _chunks(*a, **kw): + for c in [b"hello"]: + yield c + storage.iter_object_range = _chunks + storage.stat_object = AsyncMock(return_value=MagicMock(size=5)) + + from fileflash.agents.harness.tool_registry import ToolContext + ctx = ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=storage) + output = await spec.handler(ctx, {"fileId": "7"}) + assert output["content"] == "hello" + assert output["mime"] == "text/plain" + assert output["bytesReturned"] == 5 + + +@pytest.mark.asyncio +async def test_read_file_binary_returns_no_raw_bytes(): + _ensure_registered() + spec = REGISTRY.get("drive.readFile") + db = AsyncMock() + file_row = MagicMock() + file_row.file_id = 8 + file_row.owner_id = 1 + file_row.file_name = "pic.png" + file_row.mime_type = "image/png" + file_row.file_ext = ".png" + file_row.file_size = 2048 + file_row.storage_object_id = 4 + file_row.status = "active" + file_row.is_latest = True + db.scalar = AsyncMock(side_effect=[file_row, MagicMock(object_key="obj-key")]) + + storage = AsyncMock() + storage.stat_object = AsyncMock(return_value=MagicMock(size=2048)) + + from fileflash.agents.harness.tool_registry import ToolContext + ctx = ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=storage) + output = await spec.handler(ctx, {"fileId": "8"}) + assert "content" not in output or output.get("content") is None + assert output["truncated"] is True + + +@pytest.mark.asyncio +async def test_read_file_other_user_returns_404(): + _ensure_registered() + spec = REGISTRY.get("drive.readFile") + db = AsyncMock() + db.scalar = AsyncMock(return_value=None) # not found / not owned + from fileflash.agents.harness.tool_registry import ToolContext + ctx = ToolContext(db=db, user_id=1, file_service=None, folder_service=None, storage_reader=AsyncMock()) + with pytest.raises(ApiError) as exc: + await spec.handler(ctx, {"fileId": "999"}) + assert exc.value.status_code == 404 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd app && python -m pytest tests/test_agent_read_file.py -v` +Expected: FAIL — `KeyError: 'drive.readFile'` (not registered yet). + +- [ ] **Step 3: Add `_read_file` handler + registration to `drive.py`** + +Add to `app/src/fileflash/agents/tools/drive.py` (near the other handlers, before the `REGISTRY.register(...)` block). Add `StorageObject` to imports (already imported). Add the handler: + +```python +_TEXT_MIME_ALLOWLIST = ( + "text/", + "application/json", + "application/xml", + "application/x-yaml", + "application/javascript", + "application/x-sh", + "application/pdf", +) + + +async def _read_file(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + file_id = _parse_positive_int(_required_text(args, "fileId", "id"), "fileId") + max_bytes = _int_arg(args.get("maxBytes"), default=262144, minimum=1, maximum=1_048_576) + offset = _int_arg(args.get("offset"), default=0, minimum=0) + + row = await ctx.db.scalar( + select(File).where( + and_( + File.file_id == file_id, + File.owner_id == ctx.user_id, + File.status == FileStatus.ACTIVE, + File.is_latest.is_(True), + ) + ) + ) + if row is None: + raise ApiError(status_code=404, code=404, message="File not found") + + storage = await ctx.db.get(StorageObject, int(row.storage_object_id)) + if storage is None or ctx.storage_reader is None: + raise ApiError(status_code=503, code=503, message="Object storage unavailable") + + mime = _resolved_mime(row) + object_key = str(storage.object_key) + stat = await ctx.storage_reader.stat_object(object_key=object_key) + size = int(stat.size) + + if not mime.lower().startswith(_TEXT_MIME_ALLOWLIST): + return { + "fileId": str(file_id), + "name": str(row.file_name), + "mime": mime, + "size": size, + "truncated": True, + "bytesReturned": 0, + "note": "Binary content not sent to model.", + } + + end = min(offset + max_bytes - 1, size - 1) if size > 0 else 0 + chunks: list[bytes] = [] + received = 0 + async for chunk in ctx.storage_reader.iter_object_range( + object_key=object_key, start=offset, end=end + ): + chunks.append(chunk) + received += len(chunk) + content_bytes = b"".join(chunks) + try: + content = content_bytes.decode("utf-8", errors="replace") + except Exception: + content = content_bytes.decode("latin-1", errors="replace") + + return { + "fileId": str(file_id), + "name": str(row.file_name), + "mime": mime, + "size": size, + "content": content, + "truncated": (offset + received) < size, + "bytesReturned": received, + "offset": offset, + } +``` + +Then register the tool (add to the `REGISTRY.register(...)` block at the bottom): + +```python +REGISTRY.register( + ToolSpec( + name="drive.readFile", + description=( + "Read text content of a file the user owns. Returns up to maxBytes; " + "binary files are not returned directly. Subject to dataPolicy." + ), + input_schema=_schema( + { + "fileId": _FILE_ID, + "maxBytes": { + "type": "integer", + "minimum": 1, + "maximum": 1048576, + "default": 262144, + }, + "offset": {"type": "integer", "minimum": 0, "default": 0}, + }, + required=["fileId"], + ), + side_effect="read", + risk_level="medium", + requires_confirmation=False, + handler=_read_file, + ) +) +``` + +- [ ] **Step 4: Wire `storage_reader` into `ToolRouter`** + +In `app/src/fileflash/agents/harness/router.py`, update `ToolRouter` to build a `MinioObjectStorageClient` and pass it into `ToolContext`. Replace the `ToolRouter` class: + +```python +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...core.settings import Settings, get_settings +from ...services.file import FileService +from ...services.folder import FolderService +from ...s3.minio_client import MinioObjectStorageClient +from .tool_registry import REGISTRY, ToolContext + + +@dataclass(slots=True) +class ToolCall: + tool_name: str + arguments: dict[str, Any] + + +class ToolRouter: + def __init__( + self, + *, + db: AsyncSession, + user_id: int, + settings: Settings | None = None, + storage_reader: MinioObjectStorageClient | None = None, + ) -> None: + self.db = db + self.user_id = user_id + self.settings = settings or get_settings() + self.file_service = FileService(db=db) + self.folder_service = FolderService(db=db) + self._storage_reader = storage_reader + + def _resolve_storage_reader(self) -> MinioObjectStorageClient | None: + if self._storage_reader is not None: + return self._storage_reader + try: + return MinioObjectStorageClient.from_settings(self.settings) + except Exception: + return None + + async def dispatch(self, call: ToolCall) -> dict[str, Any]: + tool_name = str(call.tool_name or "").strip() + try: + spec = REGISTRY.get(tool_name) + except KeyError as exc: + raise ApiError( + status_code=400, + code=400, + message=f"Unsupported agent tool: {tool_name}", + ) from exc + + ctx = ToolContext( + db=self.db, + user_id=self.user_id, + file_service=self.file_service, + folder_service=self.folder_service, + storage_reader=self._resolve_storage_reader(), + ) + return await spec.handler(ctx, dict(call.arguments or {})) + + +__all__ = ["ToolCall", "ToolRouter"] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd app && python -m pytest tests/test_agent_read_file.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 6: Commit** + +```bash +git add app/src/fileflash/agents/tools/drive.py app/src/fileflash/agents/harness/router.py app/tests/test_agent_read_file.py +git commit -m "feat(agent): add drive.readFile tool with dataPolicy-aware binary guard" +``` + +--- + +## Task 4: Wire `AgentUserSetting` defaults + settings env vars + +**Files:** +- Modify: `app/src/fileflash/core/settings.py` +- Modify: `app/src/fileflash/agents/runtime/plan_runner.py` (defaults-merge step — partial, completed in Task 6) +- Test: `app/tests/test_agent_permission.py` (extend) + +**Interfaces:** +- Produces: `agent_read_file_max_bytes`, `agent_skill_candidate_k` settings; `_apply_setting_defaults(request, setting)` helper. +- Consumes: `AgentSettingsRepository.get_by_user_id` (NOT the scaffold `SettingsService`). + +- [ ] **Step 1: Write the failing test** + +Append to `app/tests/test_agent_permission.py`: + +```python +from fileflash.agents.harness.permission import _merge_data_policy +from fileflash.models import AgentUserSetting + + +def test_setting_default_data_policy_merges_take_strictest_bytes(): + setting = AgentUserSetting( + user_id=1, + default_data_policy_json={"allowFileContent": True, "maxReadBytes": 512}, + ) + merged = _merge_data_policy( + AgentDataPolicy(allow_file_content=True, max_read_bytes=4096, allowed_mime_types=["*/*"]), + setting, + ) + assert merged.max_read_bytes == 512 # min wins +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cd app && python -m pytest tests/test_agent_permission.py::test_setting_default_data_policy_merges_take_strictest_bytes -v` +Expected: PASS (already implemented in Task 1's `_merge_data_policy`). This is a regression guard. + +- [ ] **Step 3: Add settings fields** + +In `app/src/fileflash/core/settings.py`, after `agent_job_max_tool_calls` (line 161): + +```python + agent_read_file_max_bytes: int = Field(default=1048576, alias="AGENT_READ_FILE_MAX_BYTES") + agent_skill_candidate_k: int = Field(default=3, alias="AGENT_SKILL_CANDIDATE_K") +``` + +- [ ] **Step 4: Add `_apply_setting_defaults` helper to `permission.py`** + +Append to `app/src/fileflash/agents/harness/permission.py`: + +```python +def _apply_setting_defaults( + request: PlanAgentRequest, setting: AgentUserSetting | None +) -> PlanAgentRequest: + if setting is None: + return request + merged_policy = _merge_data_policy(request.data_policy, setting) + budget = request.hints.budget_tokens + if budget == 8000 and setting.default_budget_tokens: + budget = int(setting.default_budget_tokens) + max_steps = request.hints.max_steps + if max_steps == 12 and setting.default_max_steps: + max_steps = int(setting.default_max_steps) + return request.model_copy( + update={ + "data_policy": merged_policy, + "hints": request.hints.model_copy( + update={"budget_tokens": budget, "max_steps": max_steps} + ), + } + ) + + +__all__ = ["EffectivePermission", "PermissionResolver", "_apply_setting_defaults"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/src/fileflash/core/settings.py app/src/fileflash/agents/harness/permission.py app/tests/test_agent_permission.py +git commit -m "feat(agent): add read_file/skill_candidate_k settings and setting defaults merge" +``` + +--- + +## Task 5: `agent.useSkill` meta-tool + planner interception + +**Files:** +- Create: `app/src/fileflash/agents/harness/skill_tool.py` +- Modify: `app/src/fileflash/agents/runtime/plan_runner.py` +- Test: `app/tests/test_agent_use_skill.py` + +**Interfaces:** +- Produces: `agent.useSkill` registered in `REGISTRY` (schema only, for the LLM); `bind_skill_in_planner(db, user_id, skill_key, candidates, request, setting, current_permission) -> tuple[EffectivePermission, dict]` interceptor. +- Consumes: `AgentSkillRepository.get_by_key`, `PermissionResolver`, `EffectivePermission`. + +- [ ] **Step 1: Write the failing tests** + +Create `app/tests/test_agent_use_skill.py`: + +```python +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from fileflash.agents.harness.permission import PermissionResolver +from fileflash.agents.harness.skill_tool import bind_skill_in_planner +from fileflash.agents.harness.tool_registry import REGISTRY +from fileflash.schemas.agent import PlanAgentRequest + + +def _request() -> PlanAgentRequest: + return PlanAgentRequest.model_validate( + {"chatSessionId": "1", "input": "organize my downloads", "context": {"rootFolderId": "root"}} + ) + + +@pytest.mark.asyncio +async def test_useskill_registers_in_registry_for_llm(): + import fileflash.agents.tools # noqa: F401 + spec = REGISTRY.get("agent.useSkill") + assert spec is not None + assert spec.side_effect == "read" + + +@pytest.mark.asyncio +async def test_bind_skill_narrows_allowed_tools(monkeypatch): + skill = MagicMock() + skill.skill_key = "organizeByType" + skill.tool_whitelist_json = ["drive.listFolder", "drive.createFolder", "drive.moveFile"] + skill.name = "organizeByType" + skill.description = "" + skill.triggers_text = "" + skill.plan_template_json = {} + skill.search_text = "" + + repo = MagicMock() + repo.get_by_key = AsyncMock(return_value=skill) + + db = AsyncMock() + # AgentSkillRepository constructed with db; patch it + import fileflash.agents.runtime.plan_runner as plan_module + monkeypatch.setattr(plan_module, "AgentSkillRepository", lambda d: repo) + + base_perm = await PermissionResolver().effective( + request=_request(), setting=None, skill=None, high_risk_confirmed=False + ) + new_perm, payload = await bind_skill_in_planner( + db=db, + user_id=1, + skill_key="organizeByType", + candidates=[skill], + request=_request(), + setting=None, + current_permission=base_perm, + ) + assert payload["bound"] is True + assert "drive.deleteFile" not in new_perm.allowed_tools + assert "drive.moveFile" in new_perm.allowed_tools + assert new_perm.skill_key == "organizeByType" + + +@pytest.mark.asyncio +async def test_bind_skill_unknown_key_returns_error(monkeypatch): + repo = MagicMock() + repo.get_by_key = AsyncMock(return_value=None) + db = AsyncMock() + import fileflash.agents.runtime.plan_runner as plan_module + monkeypatch.setattr(plan_module, "AgentSkillRepository", lambda d: repo) + base_perm = await PermissionResolver().effective( + request=_request(), setting=None, skill=None, high_risk_confirmed=False + ) + _, payload = await bind_skill_in_planner( + db=db, + user_id=1, + skill_key="nope", + candidates=[], + request=_request(), + setting=None, + current_permission=base_perm, + ) + assert payload["bound"] is False + assert "unknown" in payload["message"].lower() or "not found" in payload["message"].lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd app && python -m pytest tests/test_agent_use_skill.py -v` +Expected: FAIL — `ModuleNotFoundError: fileflash.agents.harness.skill_tool` + +- [ ] **Step 3: Create `skill_tool.py`** + +Create `app/src/fileflash/agents/harness/skill_tool.py`: + +```python +from __future__ import annotations + +from typing import Any + +from ...core.errors import ApiError +from ...schemas.agent import PlanAgentRequest +from ...models import AgentUserSetting +from ...repositories import AgentSkillRepository +from .permission import EffectivePermission, PermissionResolver +from .tool_registry import REGISTRY, ToolContext, ToolSpec + + +async def _use_skill_handler(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + # This handler is never reached at runtime — the planner intercepts agent.useSkill + # before dispatch. It exists only so the tool has a valid handler for registration. + return {"bound": False, "message": "agent.useSkill must be intercepted by the planner."} + + +def register_use_skill_tool() -> None: + try: + REGISTRY.get("agent.useSkill") + return # already registered + except KeyError: + pass + REGISTRY.register( + ToolSpec( + name="agent.useSkill", + description=( + "Adopt a skill to constrain your tool set to that skill's whitelist. " + "Call once during planning if a skill fits; optional. Returns the bound " + "tool list. Use skillKey 'none' to decline all skills. " + "Cannot be used during execution." + ), + input_schema={ + "type": "object", + "properties": { + "skillKey": { + "type": "string", + "description": "One of the offered skill keys, or 'none' to decline.", + } + }, + "required": ["skillKey"], + }, + side_effect="read", + risk_level="low", + requires_confirmation=False, + handler=_use_skill_handler, + ) + ) + + +async def bind_skill_in_planner( + *, + db: Any, + user_id: int, + skill_key: str, + candidates: list[Any], + request: PlanAgentRequest, + setting: AgentUserSetting | None, + current_permission: EffectivePermission, +) -> tuple[EffectivePermission, dict[str, Any]]: + if skill_key == "none": + return current_permission, {"bound": False, "declined": True, "skillKey": "none"} + candidate_keys = {getattr(c, "skill_key", None) for c in candidates} + if skill_key not in candidate_keys: + return current_permission, { + "bound": False, + "skillKey": skill_key, + "message": f"Unknown or unoffered skill key: {skill_key}", + } + repo = AgentSkillRepository(db) + skill = await repo.get_by_key(skill_key=skill_key, user_id=user_id) + if skill is None: + return current_permission, { + "bound": False, + "skillKey": skill_key, + "message": f"Skill not found: {skill_key}", + } + new_perm = await PermissionResolver().effective( + request=request, + setting=setting, + skill=skill, + high_risk_confirmed=current_permission.high_risk_confirmed, + ) + return new_perm, { + "bound": True, + "skillKey": skill_key, + "allowedTools": sorted(new_perm.allowed_tools), + } + + +# Register on import so the LLM sees the tool whenever builtin tools are registered. +register_use_skill_tool() + + +__all__ = ["bind_skill_in_planner", "register_use_skill_tool"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && python -m pytest tests/test_agent_use_skill.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/fileflash/agents/harness/skill_tool.py app/tests/test_agent_use_skill.py +git commit -m "feat(agent): add agent.useSkill meta-tool and planner bind interceptor" +``` + +--- + +## Task 6: Integrate permission + skills + setting defaults into `PlanRunner` + +**Files:** +- Modify: `app/src/fileflash/agents/runtime/plan_runner.py` +- Test: `app/tests/test_agent_plan_execute_runtime.py` (extend) + +**Interfaces:** +- Consumes: `PermissionResolver`, `EffectivePermission`, `PolicyGuard.evaluate(phase="planning")`, `bind_skill_in_planner`, `_apply_setting_defaults`, `AgentSettingsRepository`, `agent_skill_candidate_k` setting. +- Produces: planner that injects top-K skill menu into system prompt, intercepts `agent.useSkill`, gates exploration via `PolicyGuard`. + +- [ ] **Step 1: Write the failing integration test** + +Append to `app/tests/test_agent_plan_execute_runtime.py` (match existing fixture style in that file; reuse its `plan_runner`/`fake_planner`/`db` fixtures if present, else construct a `PlanRunner` with a mocked `PlannerClient`): + +```python +@pytest.mark.asyncio +async def test_plan_runner_injects_skill_menu_and_use_skill_tool(db_session, fake_planner): + # fake_planner captures the tools list passed to create_plan + from fileflash.agents.runtime.plan_runner import PlanRunner + from fileflash.models import BackgroundJob + job = BackgroundJob( + task_type="agent.plan", + requested_by=1, + payload={"chatSessionId": "1", "input": "organize my files", "context": {"rootFolderId": "root"}}, + ) + runner = PlanRunner(planner_client=fake_planner) + await runner.run(db=db_session, job=job) + tools = fake_planner.last_tools + tool_names = [t["name"] for t in tools] + assert "agent_use_skill" in tool_names # provider name for agent.useSkill + # system prompt contains a skill menu section + assert "Available skills" in fake_planner.last_system_prompt or "useSkill" in fake_planner.last_system_prompt +``` + +> Adapt `fake_planner` to record `last_tools` and `last_system_prompt`. If the existing test file already has a fake planner, extend it to capture these; otherwise add a small `FakePlanner` class implementing `create_plan`/`create_answer` that stores the args. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && python -m pytest tests/test_agent_plan_execute_runtime.py::test_plan_runner_injects_skill_menu_and_use_skill_tool -v` +Expected: FAIL — `agent.useSkill` not in tools / system prompt unchanged. + +- [ ] **Step 3: Modify `PlanRunner._run` to build permission + candidates + intercept useSkill** + +In `app/src/fileflash/agents/runtime/plan_runner.py`: + +3a. Add imports at top: + +```python +from ..harness.permission import ( + EffectivePermission, + PermissionResolver, + _apply_setting_defaults, +) +from ..harness.policy import PolicyGuard +from ..harness.skill_tool import bind_skill_in_planner +from ...repositories import AgentSettingsRepository +``` + +3b. Replace the `skill = await _choose_skill(...)` / `allowed_tools = _skill_tool_whitelist(skill)` block (lines ~72-84) with: + +```python + setting_repo = AgentSettingsRepository(db) + setting = await setting_repo.get_by_user_id(user_id) + request = _apply_setting_defaults(request, setting) + candidates = await _candidate_skills( + db, + user_id=user_id, + task_input=request.input, + prefer_skill_id=request.hints.prefer_skill_id, + k=self.settings.agent_skill_candidate_k, + ) + # If a preferred skill is forced via hint, bind it now; otherwise start + # unbound (full registry, read-only exploration) and let the LLM bind via + # agent.useSkill during planning. + forced_skill = candidates[0] if (request.hints.prefer_skill_id and candidates) else None + permission = await PermissionResolver().effective( + request=request, + setting=setting, + skill=forced_skill, + high_risk_confirmed=False, + ) + skill = forced_skill + allowed_tools = tuple(sorted(permission.allowed_tools)) + allowed_tool_set = set(allowed_tools) + exploration_tools = tuple( + tool_name + for tool_name in allowed_tools + if REGISTRY.get(tool_name).side_effect == "read" + ) + exploration_tool_set = set(exploration_tools) + # Include the useSkill meta-tool for the LLM during planning: + planning_exploration_tools = exploration_tools + ("agent.useSkill",) +``` + +3c. Replace the `_planning_tool_executor` inner function (lines ~90-142) to delegate to `PolicyGuard` and intercept `agent.useSkill`: + +```python + planner_router = ToolRouter(db=db, user_id=user_id) + policy_guard = PolicyGuard() + tool_call_budget = min(self.settings.agent_job_max_tool_calls, 32) + planned_tool_calls = 0 + planning_evidence: list[AgentPlanningEvidence] = [] + + async def _planning_tool_executor(tool_name: str, args: dict[str, Any]) -> dict[str, Any]: + nonlocal planned_tool_calls, permission, skill, allowed_tools, allowed_tool_set + planned_tool_calls += 1 + if planned_tool_calls > tool_call_budget: + raise ApiError( + status_code=400, code=400, + message="Planner exceeded exploratory tool-call budget", + ) + # Intercept the useSkill meta-tool — never dispatch it. + if tool_name == "agent.useSkill": + new_perm, payload = await bind_skill_in_planner( + db=db, + user_id=user_id, + skill_key=str(args.get("skillKey", "")), + candidates=candidates, + request=request, + setting=setting, + current_permission=permission, + ) + if payload.get("bound"): + permission = new_perm + skill = next( + (c for c in candidates if getattr(c, "skill_key", None) == payload["skillKey"]), + skill, + ) + allowed_tools = tuple(sorted(permission.allowed_tools)) + allowed_tool_set = set(allowed_tools) + if len(planning_evidence) < 12: + planning_evidence.append( + AgentPlanningEvidence( + step=planned_tool_calls, + tool=tool_name, + input=_evidence_mapping(args), + output_preview=_evidence_preview(payload), + ) + ) + return payload + # All other tools: gate via PolicyGuard (phase=planning denies writes/content-read). + decision = await policy_guard.evaluate( + ctx=ToolContext(db=db, user_id=user_id, file_service=None, folder_service=None), + action=AgentProposedAction( + step=planned_tool_calls, tool=tool_name, input=args, side_effect="read" + ), + permission=permission, + phase="planning", + ) + if not decision.allowed: + blocked = _blocked_planning_tool_result( + tool_name=tool_name, reason="; ".join(decision.reasons) + ) + if len(planning_evidence) < 12: + planning_evidence.append( + AgentPlanningEvidence( + step=planned_tool_calls, tool=tool_name, + input=_evidence_mapping(args), output_preview=_evidence_preview(blocked), + ) + ) + return blocked + output = await planner_router.dispatch(ToolCall(tool_name=tool_name, arguments=args)) + if len(planning_evidence) < 12: + planning_evidence.append( + AgentPlanningEvidence( + step=planned_tool_calls, tool=tool_name, + input=_evidence_mapping(args), output_preview=_evidence_preview(output), + ) + ) + return output +``` + +3d. Update the `tools=REGISTRY.anthropic_tools_for(exploration_tools)` call in `_create_plan` to use `planning_exploration_tools`: + +```python + tools=REGISTRY.anthropic_tools_for(planning_exploration_tools), +``` + +3e. Update `_system_prompt` to include the skill menu (pass candidates): + +```python + async def _create_plan(metadata_payload: dict[str, Any]) -> dict[str, Any]: + return await self.planner_client.create_plan( + system_prompt=_system_prompt(candidates=candidates), + user_prompt=_user_prompt( + request=request, skill=skill, allowed_tools=allowed_tools, + exploration_tools=exploration_tools, metadata=metadata_payload, + ), + max_tokens=request.hints.budget_tokens, + reasoning_effort=request.hints.reasoning_effort, + tools=REGISTRY.anthropic_tools_for(planning_exploration_tools), + tool_executor=_planning_tool_executor, + max_tool_roundtrips=6, + ) +``` + +3f. Replace the `_system_prompt()` function (line ~596): + +```python +def _system_prompt(*, candidates: list[Any] | None = None) -> str: + menu = _skill_menu(candidates or []) + return ( + "You are FileFlash Agent Planner. Build plans from tool-grounded facts, not assumptions. " + "If you need facts, first call read-only tools; then output one final JSON object that matches outputSchema. " + "Do not read or infer file contents unless you call drive.readFile and dataPolicy allows it. " + "Deletions are high risk and must be explicit. " + "Cross-step dependencies must use '$stepN.field' references only and never symbolic placeholders " + "like 'newFolderId'. " + + menu + ) + + +def _skill_menu(candidates: list[Any]) -> str: + if not candidates: + return "" + lines = [ + "You may use one of these skills if it fits the task. Each skill restricts which tools you may use. " + "To adopt a skill, call agent.useSkill with its key. You may also proceed without a skill " + "(free planning), but then only read-only exploration tools are available during planning.", + "", + "Available skills:", + ] + for c in candidates: + key = getattr(c, "skill_key", "?") + name = getattr(c, "name", key) + desc = getattr(c, "description", "") or "" + wl = getattr(c, "tool_whitelist_json", None) or [] + wl_str = ", ".join(str(t) for t in wl) if wl else "(all tools)" + lines.append(f"- {key} ({name}): {desc}. tools: {wl_str}") + return "\n".join(lines) + "\n" +``` + +3g. Add `_candidate_skills` (top-K version replacing `_choose_skill`): + +```python +async def _candidate_skills( + db: AsyncSession, + *, + user_id: int, + task_input: str, + prefer_skill_id: str | None, + k: int = 3, +) -> list[Any]: + repo = AgentSkillRepository(db) + if prefer_skill_id: + skill = await repo.get_by_key(skill_key=prefer_skill_id, user_id=user_id) + if skill is None: + raise ApiError(status_code=404, code=404, message="Preferred skill not found") + return [skill] + candidates = await repo.list_visible(user_id=user_id, limit=50) + if not candidates: + return [] + normalized_input = task_input.lower() + scored: list[tuple[int, Any]] = [] + for candidate in candidates: + haystack = ( + f"{candidate.skill_key} {candidate.name} {candidate.description} " + f"{candidate.triggers_text or ''} {candidate.search_text}" + ).lower() + score = 0 + for token in _tokens(normalized_input): + if token in haystack: + score += 2 if token in {"organize", "整理", "classify", "分类"} else 1 + if "整理" in normalized_input and "organize" in haystack: + score += 4 + scored.append((score, candidate)) + scored.sort(key=lambda pair: pair[0], reverse=True) + positive = [pair for pair in scored if pair[0] > 0] + if not positive: + return [] # no skill forced; LLM may still pick via useSkill + return [pair[1] for pair in positive[: max(1, k)]] +``` + +Keep the old `_choose_skill` function but have it delegate, OR delete it if nothing else references it (grep first). Keep `_skill_tool_whitelist` if still referenced; otherwise leave it (it's now in `permission.py`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && python -m pytest tests/test_agent_plan_execute_runtime.py -v` +Expected: PASS — including the new test + all existing plan tests (regression). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/fileflash/agents/runtime/plan_runner.py app/tests/test_agent_plan_execute_runtime.py +git commit -m "feat(agent): inject skill menu, intercept useSkill, gate planning via PolicyGuard" +``` + +--- + +## Task 7: Integrate `PolicyGuard.evaluate` into `ExecuteRunner` + `denied` audit + +**Files:** +- Modify: `app/src/fileflash/agents/runtime/execute_runner.py` +- Test: `app/tests/test_agent_plan_execute_runtime.py` (extend) + +**Interfaces:** +- Consumes: `PermissionResolver`, `EffectivePermission`, `PolicyGuard.evaluate(phase="executing")`, `AgentSettingsRepository`, `AgentActionLogRepository`. +- Produces: denied actions written as `AgentActionLog(status="denied")` + `tool.failed` event with `{denied: true, reasons}`. + +- [ ] **Step 1: Write the failing test** + +Append to `app/tests/test_agent_plan_execute_runtime.py`: + +```python +@pytest.mark.asyncio +async def test_execute_denies_readfile_when_data_policy_disables_content(db_session, fake_answer): + from fileflash.agents.runtime.execute_runner import ExecuteRunner + from fileflash.models import BackgroundJob, AgentPlan + from fileflash.schemas.agent import AgentProposedAction + # plan has a readFile action; request data_policy allow_file_content=False + # build a plan row + job row per existing fixtures, then run ExecuteRunner + # assert: action_log status == "denied", job not failed on this step but recorded + ... +``` + +> Implement concretely using the existing `AgentPlan` + `BackgroundJob` fixture pattern in that file. The assertion: `AgentActionLog.status == "denied"` and a `tool.failed` event with `denied: True` was published. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd app && python -m pytest tests/test_agent_plan_execute_runtime.py::test_execute_denies_readfile_when_data_policy_disables_content -v` +Expected: FAIL — current execute runner does not write `denied`. + +- [ ] **Step 3: Modify `ExecuteRunner` to build permission and call `PolicyGuard.evaluate`** + +In `app/src/fileflash/agents/runtime/execute_runner.py`: + +3a. Add imports: + +```python +from ..harness.permission import EffectivePermission, PermissionResolver, _apply_setting_defaults +from ..harness.policy import PolicyGuard +from ..harness.tool_registry import ToolContext +from ...repositories import AgentSettingsRepository +from ...schemas.agent import AgentDataPolicy +``` + +3b. In `_run`, after loading `actions` and `high_risk_confirmed` (line ~87), build the permission: + +```python + # Load the original plan request to recover execution_policy / data_policy. + plan_request = PlanAgentRequest.model_validate(dict(plan.context_json or {})) if plan.context_json else None + setting = await AgentSettingsRepository(db).get_by_user_id(int(job.requested_by)) + # Fallback: construct a minimal request from plan fields if context_json absent. + base_request = plan_request or PlanAgentRequest.model_validate( + {"chatSessionId": request.chat_session_id, "input": "", "context": {"rootFolderId": "root"}, + "executionPolicy": "confirm", "dataPolicy": {"allowFileContent": False}} + ) + base_request = _apply_setting_defaults(base_request, setting) + permission = await PermissionResolver().effective( + request=base_request, + setting=setting, + skill=None, # skill already baked into plan's allowed_tools via chosen_skill + high_risk_confirmed=high_risk_confirmed, + ) + # Narrow permission to the plan's chosen skill if recorded. + if getattr(plan, "chosen_skill_id", None): + from ...repositories import AgentSkillRepository + skill = await AgentSkillRepository(db).get_by_key( + skill_key=str(plan.chosen_skill_id), user_id=int(job.requested_by) + ) + if skill is not None: + permission = await PermissionResolver().effective( + request=base_request, setting=setting, skill=skill, + high_risk_confirmed=high_risk_confirmed, + ) +``` + +3c. Replace the per-step `policy_guard.evaluate_tool_call(...)` block (lines ~118-128) with the new evaluate + denied audit: + +```python + decision = await self.policy_guard.evaluate( + ctx=ToolContext( + db=db, user_id=int(job.requested_by), + file_service=None, folder_service=None, + ), + action=action, + permission=permission, + phase="executing", + ) + if not decision.allowed: + await action_logs.append_step( + job_id=int(job.job_id), + step_no=action.step, + tool_name=action.tool, + inputs_json=action.input, + status="denied", + started_at=datetime.now(UTC), + ) + await action_logs.finish_step( + job_id=int(job.job_id), + step_no=action.step, + outputs_json={}, + status="denied", + duration_ms=0, + error_message="; ".join(decision.reasons)[:2000], + ) + await db.commit() + await self._publish_tool( + "tool.failed", + job_id=int(job.job_id), + step=action.step, + tool=action.tool, + payload={"denied": True, "reasons": decision.reasons}, + ) + warnings.append(f"Step {action.step} denied by policy: {'; '.join(decision.reasons)}") + continue # skip this step, proceed to next (do not fail the whole job) +``` + +> Need `PlanAgentRequest` imported in execute_runner: `from ...schemas.agent import AgentExecutionResult, AgentProposedAction, ExecuteAgentRequest, PlanAgentRequest`. Also verify `plan.context_json` exists on the `AgentPlan` model — if the field is named differently (e.g. `context_json`), use the actual attribute; if absent, the fallback minimal request is used. + +3d. Ensure `datetime` is imported (already imported at top: `from datetime import UTC, datetime`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && python -m pytest tests/test_agent_plan_execute_runtime.py -v` +Expected: PASS — new denied test + all existing execute tests (regression). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/fileflash/agents/runtime/execute_runner.py app/tests/test_agent_plan_execute_runtime.py +git commit -m "feat(agent): enforce PolicyGuard in execute and record denied actions" +``` + +--- + +## Task 8: Seed 2 additional builtin skills (migration) + routes test + +> **Plan correction (discovered during execution):** `V13__agent_builtin_skills.sql` already seeds `builtin:organizeByType` (with a working whitelist including deleteFile/deleteFolder), and `V15__agent_tool_registry_skills.sql` updates it. Do NOT re-seed organizeByType or create a conflicting migration. This task adds ONLY the two missing skills (`builtin:dedupScan`, `builtin:listAndSummarize`), matching V13's style (`builtin:` prefix, `ON CONFLICT (skill_key) DO UPDATE`). + +**Files:** +- Create: `docker/flyway/migrations/V17__agent_builtin_skills_extra.sql` +- Modify: `app/tests/test_agent_routes.py` (extend) + +**Interfaces:** +- Produces: 2 new builtin skill rows (`builtin:dedupScan`, `builtin:listAndSummarize`), `visibility='global'`, `owner_user_id=NULL`. Leaves `builtin:organizeByType` (V13/V15) untouched. +- Consumes: existing `agent_skill` table columns. + +- [ ] **Step 1: Verify the `agent_skill` table columns and existing seeds** + +Run: `cd app && python -c "from fileflash.models import AgentSkill; from fileflash.models.enums import AgentSkillVisibility; print([c.name for c in AgentSkill.__table__.columns]); print([e.value for e in AgentSkillVisibility])"` +Expected: columns include `skill_key, name, description, triggers_text, tool_whitelist_json, plan_template_json, inputs_schema_json, outputs_schema_json, visibility, owner_user_id`; visibility enum values are lowercase `['global', 'private']`. + +Confirm `V13__agent_builtin_skills.sql` already seeds `builtin:organizeByType` — do NOT duplicate it. + +- [ ] **Step 2: Write the seed migration (2 new skills only)** + +Create `docker/flyway/migrations/V17__agent_builtin_skills_extra.sql`: + +```sql +INSERT INTO agent_skill ( + skill_key, + name, + description, + triggers_text, + tool_whitelist_json, + plan_template_json, + inputs_schema_json, + outputs_schema_json, + visibility, + owner_user_id +) +VALUES +( + 'builtin:dedupScan', + 'Dedup Scan', + 'Find duplicate files by content hash or name+size and propose deletion of duplicates.', + 'duplicate files, dedup, find duplicates, 重复文件, 去重', + '["drive.listFolder","drive.getFileInfo","drive.findDuplicates","drive.deleteFile"]'::jsonb, + '{"strategy":"Group duplicates by content hash; propose keeping the oldest and deleting the rest. Deletions are high risk and require explicit confirmation."}'::jsonb, + '{"type":"object","required":["sourceFolderId"],"properties":{"sourceFolderId":{"type":"string"},"by":{"type":"string","enum":["hash","nameSize"],"default":"hash"}}}'::jsonb, + '{"type":"object"}'::jsonb, + 'global', + NULL +), +( + 'builtin:listAndSummarize', + 'List And Summarize', + 'List the contents of a folder and produce a statistical summary by category.', + 'list files, summarize, folder summary, statistics, 列出文件, 统计摘要', + '["drive.listFolder","drive.countFiles","drive.getFileInfo","drive.statsByCategory"]'::jsonb, + '{"strategy":"List direct children, then compute counts and sizes by category. Read-only; no writes."}'::jsonb, + '{"type":"object","required":["sourceFolderId"],"properties":{"sourceFolderId":{"type":"string"}}}'::jsonb, + '{"type":"object"}'::jsonb, + 'global', + NULL +) +ON CONFLICT (skill_key) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + triggers_text = EXCLUDED.triggers_text, + tool_whitelist_json = EXCLUDED.tool_whitelist_json, + plan_template_json = EXCLUDED.plan_template_json, + inputs_schema_json = EXCLUDED.inputs_schema_json, + outputs_schema_json = EXCLUDED.outputs_schema_json, + visibility = EXCLUDED.visibility, + owner_user_id = EXCLUDED.owner_user_id; +``` + +> The `ON CONFLICT (skill_key) DO UPDATE` (upsert) matches V13's style and makes the migration idempotent/re-runnable. The `agent_skill` table has a UniqueConstraint on `skill_key` (verified). + +- [ ] **Step 3: Write the routes test** + +Append to `app/tests/test_agent_routes.py`: + +```python +@pytest.mark.asyncio +async def test_plan_request_with_data_policy_flows_through(client, auth_headers): + response = await client.post( + "/api/v1/agent/plan", + headers=auth_headers, + json={ + "chatSessionId": "1", + "input": "list my files", + "context": {"rootFolderId": "root"}, + "dataPolicy": {"allowFileContent": True, "maxReadBytes": 512000, "allowedMimeTypes": ["text/*"]}, + }, + ) + assert response.status_code in (200, 202) + # The plan job is enqueued; dataPolicy is carried into the job payload. +``` + +> Adapt endpoint path + auth fixture to match the existing test file's conventions (some files use `/agent/plan` without `/api/v1`; match what's there). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd app && python -m pytest tests/test_agent_routes.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add docker/flyway/migrations/V17__agent_builtin_skills_extra.sql app/tests/test_agent_routes.py +git commit -m "feat(agent): seed 3 builtin skills and cover dataPolicy plan flow" +``` + +--- + +## Task 9: Full regression + final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full agent test suite** + +Run: `cd app && python -m pytest tests/test_agent_permission.py tests/test_agent_read_file.py tests/test_agent_use_skill.py tests/test_agent_plan_execute_runtime.py tests/test_agent_routes.py tests/test_agent_a_end_to_end.py -v` +Expected: ALL PASS. + +- [ ] **Step 2: Verify no broken imports across the package** + +Run: `cd app && python -c "from fileflash.agents.harness.permission import PermissionResolver, EffectivePermission; from fileflash.agents.harness.policy import PolicyGuard; from fileflash.agents.harness.skill_tool import bind_skill_in_planner; from fileflash.agents.runtime.plan_runner import PlanRunner; from fileflash.agents.runtime.execute_runner import ExecuteRunner; print('imports OK')"` +Expected: `imports OK` + +- [ ] **Step 3: Verify `agent.useSkill` and `drive.readFile` are registered** + +Run: `cd app && python -c "import fileflash.agents.tools; from fileflash.agents.harness.tool_registry import REGISTRY; print(sorted(REGISTRY.all_names()))"` +Expected: list containing `agent.useSkill` and `drive.readFile`. + +- [ ] **Step 4: Final commit (if any stray changes)** + +```bash +git status +# if clean, nothing to commit; otherwise commit +``` + +--- + +## Self-Review (completed by plan author) + +**Spec coverage check:** +- §1 Architecture (single choke point) → Task 2 (`PolicyGuard.evaluate`), Tasks 6+7 (both runners call it). +- §2 Permission model (three-axis intersection, `EffectivePermission`, `PermissionResolver`) → Task 1. +- §3 `drive.readFile` + dataPolicy gating + binary guard → Task 3 (+ Task 2's `_check_content_read`). +- §4 LLM-invocable skills (`agent.useSkill`, intercept, top-K menu) → Tasks 5 + 6. +- §5 Data model + `AgentUserSetting` defaults + env vars → Tasks 4 + 7 + 8. +- §6 Testing + rollout → all tasks are TDD; Task 9 is full regression. + +**Placeholder scan:** None — every code step contains real code. (The two `...` placeholders in Task 7 step 1 and Task 8 step 3 test bodies are intentional "adapt to existing fixture" notes with concrete guidance, not implementation placeholders.) + +**Type consistency:** `EffectivePermission` fields (`execution_policy`, `data_policy`, `allowed_tools`, `skill_key`, `deny_read_content`, `high_risk_confirmed`) used consistently across Tasks 1, 2, 6, 7. `PolicyGuard.evaluate(*, ctx, action, permission, phase)` signature consistent. `bind_skill_in_planner` signature consistent between Task 5 and Task 6. `ToolContext.storage_reader` added in Task 2, used in Task 3, referenced in Task 7 (passed `None` — acceptable since execute-side `readFile` mime check uses `ctx.db` not storage; storage is only needed in the handler dispatch which constructs its own ctx in execute via `ToolRouter`). + +**Known implementation risks flagged for the executor:** +1. `plan.context_json` attribute name in Task 7 — verify against `AgentPlan` model; the fallback handles absence. +2. `AgentSkillVisibility` enum value casing in Task 8 — verify `'global'` matches. +3. Existing `fake_planner`/`fake_answer`/`db_session`/`client`/`auth_headers` fixtures — adapt new tests to the actual fixture names in each test file (they may differ from the illustrative names used here). diff --git a/docs/superpowers/specs/2026-07-09-agent-permission-skills-design.md b/docs/superpowers/specs/2026-07-09-agent-permission-skills-design.md new file mode 100644 index 0000000..0d518f0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-agent-permission-skills-design.md @@ -0,0 +1,457 @@ +# Agent 权限层 + LLM 可选用 Skills 设计(2026-07-09) + +## 背景与目标 + +FileFlash agent 子系统已完成三块地基(见 [2026-05-26-agent-improvements-design.md](2026-05-26-agent-improvements-design.md)): + +- **子项目 A** 交互/反馈层(Redis pub/sub event bus、inbox、ask 协议、pause/resume/step approve)——已落地 +- **子项目 B** ChatSession 后端化 + 软删除 + 级联中断——当前 `feat/audit` 分支进行中 +- **子项目 C** ToolRegistry + 14 个 `drive.*` 工具——已落地 + +但 [15-agent.md](../../design/modules/15-agent.md) 设计文档中规划的**权限管理**与 **Skills 可供 LLM 使用**两块尚未落地。当前现状(已核对代码): + +1. **权限层是 stub**:[policy.py:48](../../../app/src/fileflash/agents/harness/policy.py) `PolicyGuard.evaluate_tool_call` 只检查「工具是否注册」+「高危是否已确认」两条规则。没有 `executionPolicy`(planOnly/confirm/autopilot)的统一裁决,没有 `dataPolicy`(allow_file_content / max_read_bytes / allowed_mime_types)的执行——`AgentDataPolicy` schema 已定义([schemas/agent.py:42](../../../app/src/fileflash/schemas/agent.py))但**从未被读取**,是死 schema。 +2. **Skills 不可被 LLM 选用**:[plan_runner.py:313](../../../app/src/fileflash/agents/runtime/plan_runner.py) `_choose_skill` 用 keyword 评分**硬选一个** skill 注入 prompt;LLM 看不到 skill 菜单、不能选/换/拒。`plan_template_json` / `inputs_schema_json` / `outputs_schema_json` 存了但不执行、不模板化。 +3. **`AgentUserSetting` 默认值不生效**:表已有 `default_execution_policy` / `default_data_policy_json` / `default_budget_tokens` / `default_max_steps`,但运行时从不读取。 +4. **没有 readFile 工具**:`dataPolicy`(「允不允许 LLM 分析文件」)目前没有落点——没有工具读文件内容,policy 也就无从管控。 + +本设计把这三件事收敛到**一个权限裁决点**上落地,复用现有 ToolRegistry / SkillService / AgentUserSetting,不引入新基础设施。 + +## 范围决策(已与用户确认) + +1. **Skills 形态**:LLM 可选用——skill 作为 LLM 可调用的 meta-tool,模型能选/拒/换。 +2. **文件内容工具**:新增 `drive.readFile`,受 `dataPolicy` 真管控(不含 writeFile / OCR / 二进制分析)。 +3. **权限模型**:executionPolicy + dataPolicy + skill 白名单**三轴求交(取最严)**,复用 `AgentUserSetting` 作默认。不做 RBAC / per-user 工具粒度表 / per-skill 风险覆盖。 + +## 顶层架构 + +把 `PolicyGuard.evaluate_tool_call` 从「只接 `(tool_name, high_risk_confirmed)`」拓宽为「接完整 `PolicyContext`」,成为 plan 与 execute 两个循环中**唯一**的放行/拒绝点。 + +``` + ┌─────────────────────────────────────────┐ + PlanAgentRequest │ AgentUserSetting (defaults, if omitted) │ + (policy/hints) └───────────────────┬─────────────────────┘ + ▼ + PermissionResolver.effective(request, setting, skill) + → EffectivePermission { + execution_policy, + data_policy, + allowed_tools, # skill_whitelist ∩ registry + deny_read_content, # precomputed + high_risk_confirmed + } + │ + ┌────────────────────────┴───────────────────────┐ + ▼ ▼ + PlanRunner._planning_tool_executor ExecuteRunner per-step loop + (skill-bound, read-only, budget) (replay actions) + │ │ + └──────────────► PolicyGuard.evaluate( ◄──────┘ + ctx: ToolContext, + action: {tool, input}, + permission: EffectivePermission, + phase: "planning"|"executing" + ) → PolicyDecision {allowed, reasons[]} + │ + ┌───────────┴────────────┐ + ▼ ▼ + ToolRouter.dispatch structured denial + (real run) (ActionLog status="denied" + + tool.failed 事件) +``` + +### 三个新增/改动的单元(每个单一职责) + +| 单元 | 文件 | 职责 | +|---|---|---| +| `PermissionResolver` | `agents/harness/permission.py`(新) | 合并 request policy + `AgentUserSetting` 默认 + skill 白名单 + dataPolicy,产出单一 `EffectivePermission`(取最严)。纯计算,除读 skill 行外无 I/O。 | +| `PolicyGuard`(拓宽) | `agents/harness/policy.py`(现有,重写) | 唯一放行/拒绝点。接 `EffectivePermission` + action + phase,返回 `PolicyDecision{allowed, reasons[]}`。替换现有 2 规则 stub。 | +| `agent.useSkill` meta-tool | `agents/harness/skill_tool.py`(新)+ registry hook | LLM 可调用的 skill 绑定面。绑定后重跑 `PermissionResolver` 收窄 `allowed_tools`。 | + +**关键性质**:工具 handler 里的 ownership scoping(`File.owner_id == ctx.user_id`,今天真正的安全网)**保持不动**。权限层是**叠加**的——它回答「LLM 是否被允许*尝试*这个操作」,handler 仍回答「这个用户是否*拥有*这个资源」。拒绝永远是结构化的(reasons 列表),不是裸 403。 + +## 权限模型:`EffectivePermission` 与 `PolicyGuard` + +### 三轴求交(取最严) + +1. **executionPolicy**(`planOnly` | `confirm` | `autopilot`)——控制「写操作是否需确认」,不收窄工具集。 + - `planOnly`:execute job 根本不允许入队(在 `ExecuteService` 强制,已部分存在)。 + - `confirm`(默认):任何 `write` action 置 `requires_confirmation=True`;高危额外需 `high_risk_confirmed`。 + - `autopilot`:写操作无需逐步确认;**高危仍永远需显式 `high_risk_confirmed`**(删除永不自动执行)。 + +2. **dataPolicy**(`AgentDataPolicy`:`allow_file_content` / `max_read_bytes` / `allowed_mime_types`)——控制「文件内容访问」。仅对内容读取工具(本轮仅 `drive.readFile`)生效。这是「允不允许 LLM 分析文件」的杠杆。 + - `allow_file_content=false` → `readFile` 拒绝。 + - `allow_file_content=true` → 仍受 `max_read_bytes`(默认 1 MiB)与 `allowed_mime_types`(默认 `["*/*"]`)约束。 + +3. **skill 白名单**(`AgentSkill.tool_whitelist_json`)——收窄工具集。skill 无白名单时以全 registry 为基;skill 只能收窄,不能放宽。 + +**求交规则**:`allowed_tools = registry ∩ skill_whitelist`;剩余工具中「读内容」类工具在 dispatch 时再受 dataPolicy 约束(因为 mime/size 决策依赖 action input,即具体哪个文件,不能在集合计算阶段定)。 + +### `EffectivePermission`(dataclass) + +```python +@dataclass(frozen=True, slots=True) +class EffectivePermission: + execution_policy: AgentExecutionPolicy + data_policy: AgentDataPolicy + allowed_tools: frozenset[str] # registry ∩ skill_whitelist + skill_key: str | None # 已绑定 skill,若无则 None + deny_read_content: bool # 预计算:not data_policy.allow_file_content + high_risk_confirmed: bool # execute 期来自 AgentApproval;plan 期恒 False +``` + +`high_risk_confirmed` 的来源分阶段:**plan 期**恒为 `False`(planning 不执行高危动作,写工具本就被拦);**execute 期**取自 `ExecuteAgentRequest.approval.high_risk_confirmed`(已在 `ExecuteService` 校验过)。故 `PermissionResolver.effective` 接一个 `high_risk_confirmed` 入参,由调用方按阶段传入。 + +### `PermissionResolver`(合并器) + +```python +class PermissionResolver: + async def effective( + self, *, request: PlanAgentRequest, setting: AgentUserSetting | None, skill + ) -> EffectivePermission: + # 1. execution_policy: request 覆盖 setting.default_execution_policy + # 2. data_policy: request.data_policy 与 setting.default_data_policy_json 合并(取最严) + # 3. skill_whitelist: 复用现有 _skill_tool_whitelist(skill) 逻辑 + # 4. allowed_tools = frozenset(registry ∩ skill_whitelist) + # 5. deny_read_content = not data_policy.allow_file_content +``` + +dataPolicy 取最严合并:`allow_file_content = a and b`;`max_read_bytes = min(a, b)`;`allowed_mime_types = 两个 glob 列表的交集`(交集为空 → 实质拒绝所有内容读,作为 reason 显式呈现)。 + +### `PolicyGuard.evaluate`(裁决点——重写) + +```python +async def evaluate( + self, *, ctx: ToolContext, action, permission: EffectivePermission, phase +) -> PolicyDecision: + spec = _lookup(action.tool) # 注册?否则 deny "unknown tool" + if action.tool not in permission.allowed_tools: + return deny("tool not permitted by active skill/policy") + if spec.side_effect == "read" and _is_content_read(action.tool): + if permission.deny_read_content: + return deny("file content access disabled by dataPolicy") + if not _mime_allowed(action.input, permission.data_policy, ctx): + return deny("file mime not in allowed_mime_types") + if _byte_range(action.input) > permission.data_policy.max_read_bytes: + return deny("requested bytes exceed max_read_bytes") + if spec.risk_level == "high" and not permission.high_risk_confirmed: + return deny("high-risk action requires explicit confirmation") + if permission.execution_policy == "planOnly" and phase == "executing": + return deny("planOnly policy forbids execution") + return allow() +``` + +两个调用点改为传 `EffectivePermission`: + +- **Planner**([plan_runner.py:99](../../../app/src/fileflash/agents/runtime/plan_runner.py))——其 `_planning_tool_executor` 现在内联 `if tool_name not in allowed_tool_set` 检查;改为委托 `PolicyGuard.evaluate(phase="planning")`。结果一致,路径单一。 +- **Execute runner**([execute_runner.py:118](../../../app/src/fileflash/agents/runtime/execute_runner.py))——替换现有只传 `high_risk_confirmed` 的调用为 `evaluate(phase="executing")`。 + +**拒绝处理**:被拒 action 返回结构化 `PolicyDecision` → execute 中抛 `ApiError(409, reasons=[...])` 并写 `ActionLog` 行 `status="denied"` + 发 `tool.failed` 事件(前端可见「为何被拒」)。planning 中返回现有 `_blocked_planning_tool_result` 形态,LLM 可反应并换路径。 + +## `drive.readFile` 工具与 dataPolicy 执行 + +让 `AgentDataPolicy` 从死 schema 变成活契约。一个新工具 + `PolicyGuard` 调用的辅助函数。 + +### `drive.readFile` 工具 + +```python +REGISTRY.register(ToolSpec( + name="drive.readFile", + description="Read text content of a file the user owns. Returns up to maxBytes; " + "binary files are not returned directly. Subject to dataPolicy.", + input_schema=_schema({ + "fileId": _FILE_ID, + "maxBytes": {"type": "integer", "minimum": 1, "maximum": 1048576, "default": 262144}, + "offset": {"type": "integer", "minimum": 0, "default": 0}, + }, required=["fileId"]), + side_effect="read", + risk_level="medium", # 内容离开 DB → 用户文件进入 LLM + requires_confirmation=False, + handler=_read_file, +)) +``` + +**Handler 行为**(`_read_file` 在 `agents/tools/drive.py`): + +1. 按 `ctx.user_id` scope 加载 `File` 行(ownership,同其他工具)。未找到 → 404,不 403(不泄露存在性)。 +2. 用现有 `resolve_file_mime_type` 解析 mime。 +3. 取 `StorageObject` + 从 MinIO 流式读字节。`ToolContext` 增 `storage_reader: MinioObjectStorageClient`([s3/minio_client.py](../../../app/src/fileflash/s3/minio_client.py) 已存在,无需新存储代码)。用其 `iter_object_range(object_key, start=offset, end=offset+maxBytes-1)` 做有界范围读,`stat_object` 取 size。读 `offset..offset+maxBytes`。 +4. **二进制守卫**:mime 不在文本类白名单(`text/*`、`application/json`、`application/pdf`、`application/xml` 等显式白名单)时,**不**返回原始字节,而返回 `{truncated: true, mime, size, note: "binary content not sent to model"}`。遵循设计文档「二进制永不直送 LLM」。 +5. 文本类 mime 返回 `{fileId, name, mime, size, content, truncated, bytesReturned}`,`content` 截断到 `maxBytes`。 + +### 为何 `risk_level="medium"`(非 low) + +对文件系统只读,但**把用户文件内容外发到 LLM provider**——这是隐私相关的副作用。medium 意味着默认 `confirm` 策略下它不会不出现在 plan 里就自动跑,且进入 cost/审计链。非高危(不删除),故不需 `high_risk_confirmed`。 + +### PolicyGuard 辅助函数(§2 引用) + +```python +_CONTENT_READ_TOOLS = frozenset({"drive.readFile"}) + +def _is_content_read(tool_name: str) -> bool: + return tool_name in _CONTENT_READ_TOOLS + +def _mime_allowed(action_input, data_policy, ctx) -> bool: + mime = _resolve_target_mime(action_input, ctx) # 懒加载 File.mime,缓存 + return _glob_any(data_policy.allowed_mime_types, mime) + +def _byte_range(action_input) -> int: + return int(action_input.get("maxBytes", 262144)) + int(action_input.get("offset", 0)) +``` + +`_resolve_target_mime` 需 DB 访问——故 `PolicyGuard.evaluate` 变 `async` 并接 `ctx: ToolContext`(已含 `db` + `user_id`)。它加载一次 `File` 行;handler 再复用同一解析(少量重复,可接受——或把解析出的 mime 经 `ToolContext` 传递,保持 guard 无状态倾向)。本设计 guard 做轻量 mime 查询,handler 做实际字节拉取。 + +### dataPolicy 合并边界情况 + +- `allowed_mime_types=[]`(空)→ 拒绝所有内容读(显式「完全不允许内容」)。 +- `allowed_mime_types=["*/*"]`(默认)→ mime 检查通过,仅 `max_read_bytes` 约束。 +- `max_read_bytes=0` → 实质等价 `allow_file_content` 关闭(任何非空读都拒)。 + +### 不做(YAGNI) + +- 不做 `writeFile`(本轮只 readFile)。 +- 不做 OCR / 图片 / 音频内容分析。 +- 不做按文件「逐个确认」prompt——request 上的 `dataPolicy`(与 user setting 合并后)*即*确认。 +- 不做分块流式送模型(单次有界读)。 + +## LLM 可选用 Skills(`agent.useSkill`) + +「要有 skills 可供 LLM 使用」的核心。把 runner 从「硬选一个 skill 注入」改为「runner 提议 top-K,LLM 经 meta-tool 选用(或拒绝),选择即时收窄 `allowed_tools`」。 + +### Skill 发现 → 注入 + +`_choose_skill` 替换为 `_candidate_skills`(复用现有 keyword 评分,但返回 **top-K** 而非一个): + +```python +async def _candidate_skills(db, *, user_id, task_input, prefer_skill_id, k=3): + # prefer_skill_id 仍优先(显式 hint)→ 返回 [该 skill] + # 否则:list_visible → 评分 → top-K;若全 0 分,返回 [](不强制 skill) +``` + +选中的候选注入 **system prompt** 作为菜单(非 user prompt): + +``` +You may use one of these skills if it fits the task. Each skill restricts which +tools you may use. To adopt a skill, call agent.useSkill with its key. You may +also proceed without a skill (free planning), but then only read-only +exploration tools are available during planning. + +Available skills: +- organizeByType (按类型整理): 按文件类型整理指定文件夹. tools: listFolder, getFileMeta, createFolder, moveFile +- dedupScan (去重扫描): 找出重复文件. tools: listFolder, getFileInfo, findDuplicates, deleteFile +- listAndSummarize (列出并摘要): 列出某文件夹内容. tools: listFolder, countFiles, getFileInfo +``` + +### `agent.useSkill` meta-tool + +```python +REGISTRY.register(ToolSpec( + name="agent.useSkill", + description="Adopt a skill to constrain your tool set to that skill's whitelist. " + "Call once during planning if a skill fits; optional. Returns the bound " + "tool list. Cannot be used during execution.", + input_schema=_schema({ + "skillKey": {"type": "string", "description": "One of the offered skill keys, or 'none' to decline."}, + }, required=["skillKey"]), + side_effect="read", # 无文件系统副作用,仅收窄权限 + risk_level="low", + requires_confirmation=False, + handler=_use_skill, +)) +``` + +**Handler 行为**——这是关键点。`useSkill` 是 *meta-tool*:不碰文件系统,它变更 planner 的 `EffectivePermission`。故不能干净地走 `ToolRouter.dispatch` → `spec.handler(ctx, args)` 路径,因为它需要写回 runner 状态。两个选项: + +**选项 4a(推荐):在 planner 的 tool-executor 拦截。** `_planning_tool_executor` 已包裹每次工具调用。对 `agent.useSkill` 特殊处理:校验 key 是否在注入的候选列表中,重跑 `PermissionResolver.effective(..., skill=chosen)` 产出新 `EffectivePermission`,换入闭包(`nonlocal permission`),返回 `{bound: true, skillKey, allowedTools: [...]}` 给 LLM。LLM 随即知道收窄后的工具集。这把 `useSkill` 排除在通用 dispatch 路径外(它不是真正的 drive 工具),且变更局部于 planner。 + +**选项 4b:作为普通工具注册,handler 有副作用。** registry 故事更干净,但 handler 需要反向引用 runner 的可变 permission 状态——耦合丑陋,且 `useSkill` 会出现在 `REGISTRY.all()` 并泄漏进 execute 侧 schema。**否决**。 + +故:`agent.useSkill` **仅为 prompt/schema 生成注册**(让 LLM 看见),但其执行在 planner 拦截,**永不**到达 `ToolRouter`。它被**排除**出 `allowed_tools` 求交逻辑与 execute 侧 dispatch(execute 重放固定 plan;skill 已绑定并烤进 plan 的 `chosen_skill`)。 + +### Planning 期间流程 + +``` +1. _candidate_skills → top-K(或 [preferred]) +2. permission = PermissionResolver.effective(request, setting, skill=None) # 全 registry,只读探索 +3. system prompt 含 skill 菜单 + useSkill 工具定义 +4. LLM 二选一: + a. 调 agent.useSkill("organizeByType") + → planner 拦截 → permission := effective(..., skill=organizeByType) + → 返回 {bound:true, allowedTools:[...]} 给 LLM + → 后续工具调用受收窄后的 permission 约束 + b. 调 agent.useSkill("none") 或从不调 + → permission 保持全 registry(仅只读探索) +5. LLM 产出 proposedActions(仅限当前 allowed_tools 内工具) +6. plan 归一化:任何 action tool ∉ permission.allowed_tools → 422(纵深防御) +7. chosen_skill 记入 AgentPlan(现有字段) +``` + +### 向后兼容 + +- `hints.prefer_skill_id` 仍生效——若设置,候选 = `[该 skill]` 且自动绑定(无需 LLM 选择)。现有调用方/测试不受影响。 +- 无 skill 匹配且 LLM 拒绝 → 自由规划 + 只读探索,同今天 `skill=None` 路径。 +- `plan_template_json` / `inputs_schema_json` / `outputs_schema_json` 本轮**存而不执行**(模板执行是 Approach B,已否决)。仅作为 skill 描述的一部分给 LLM 作指引。 + +### 预置 builtin skills + +预置 3 个(非设计文档全部 6 个——本轮 YAGNI):`organizeByType`、`dedupScan`、`listAndSummarize`。以 DB 行预置(migration 或幂等启动 seed),`visibility=global`、`owner_user_id=NULL`。对应工具均已存在。 + +## 数据模型、设置与默认值接线 + +最小新 schema。关键变更是把 `AgentUserSetting` 默认值**接入** request 路径——表已有正确列,只是运行时从不读。 + +### 无新表 + +全部复用现有表: + +- `AgentUserSetting`——已有 `default_execution_policy`、`default_data_policy_json`、`default_budget_tokens`、`default_max_steps`。**无 schema 变更**。 +- `AgentSkill`——已有 `tool_whitelist_json`、`triggers_text` 等。**无 schema 变更**。 +- `AgentPlan`——已有 `chosen_skill_id`。**无 schema 变更**。 +- `AgentActionLog`——`status` 列为 `String(20)` 自由文本(已核对 [tables_agent.py:296](../../../app/src/fileflash/models/tables_agent.py)),新增 `"denied"` 状态值**无需 migration**。 + +### `AgentUserSetting` 默认值接线 + +今天 `PlanService.enqueue_plan` 校验 request 但从不加载 user setting。在 `PlanRunner._run` 早期(计算 permission 之前)加**默认值合并步骤**: + +```python +setting = await SettingsService(db).get_for_user(user_id) # 现有 service +request = _apply_setting_defaults(request, setting) + +def _apply_setting_defaults(request, setting): + # execution_policy: request 发默认哨兵值时用 setting 默认 + # (但今天 request 总发值,主要为未来「省略」场景) + # data_policy: request.data_policy 与 setting.default_data_policy_json 合并(取最严) + # hints.budget_tokens: 若 == 默认(8000) 用 setting.default_budget_tokens + # hints.max_steps: 同模式 + return request +``` + +**取最严合并**(同 §2):`allow_file_content = req and setting`;`max_read_bytes = min`;`allowed_mime_types = 交集`。 + +即用户可在设置里设全局「永不允许读文件内容」,即便 request 说 `allow_file_content=true`,setting 仍胜(交集)。这是「完善的权限管理」——policy 在用户级执行,非仅 per-request。 + +### 新设置(env) + +| Env | 默认 | 用途 | +|---|---|---| +| `AGENT_READ_FILE_MAX_BYTES` | `1048576`(1 MiB) | `readFile` 硬上限,无视 request `maxBytes`/`max_read_bytes` | +| `AGENT_SKILL_CANDIDATE_K` | `3` | 注入 prompt 的 top-K skill 数 | + +无其他新配置。现有 `agent_job_max_tool_calls` 已约束 planning 工具调用。 + +### `EffectivePermission` 不持久化 + +每次运行从 (request, setting, skill) 计算并持有于 runner 内存。*结果*经 `AgentPlan.chosen_skill_id`(已有)与 `AgentActionLog.status="denied"` 行(新拒绝审计)持久化。permission 对象本身不新增持久化。 + +### `denied` 在 `AgentActionLog` 中的形态 + +execute 中 `PolicyGuard.evaluate` 拒绝时: + +- `status = "denied"` +- `error_message = "; ".join(reasons)` +- `inputs_json = action.input`(尝试的 input,供审计) +- `outputs_json = {}` +- 发 `tool.failed` 事件,`data={reasons, denied: true}`,前端可区分「被 policy 拒」与「运行时错误」。 + +## 测试策略 + +遵循项目惯例:真 DB + 真 services,不 mock 业务逻辑。 + +**`PermissionResolver`(单元)** + +- request policy 单独 → effective = request。 +- request + setting → 取最严合并(setting `allow_file_content=false` 胜过 request `true`;`max_read_bytes` = min;mime 交集)。 +- skill 白名单收窄 `allowed_tools`;无白名单 skill → 全 registry。 +- mime 交集为空 → `deny_read_content` 实质 true,reason 记录。 + +**`PolicyGuard.evaluate`(单元)** + +- 未知工具 → denied "unknown tool"。 +- tool ∉ allowed_tools → denied "not permitted by skill"。 +- `readFile` + `allow_file_content=false` → denied。 +- `readFile` + mime ∉ allowed_mime_types → denied。 +- `readFile` + `maxBytes > max_read_bytes` → denied。 +- 高危无 `high_risk_confirmed` → denied。 +- `planOnly` + phase=executing → denied。 +- happy path:read 工具、白名单内、policy 允许 → allowed。 + +**`drive.readFile` handler(集成,真 DB + MinIO)** + +- 文本文件 → 返回 content,truncated 标志正确。 +- 二进制文件(图片)→ 返回 `{truncated:true, note}`,无原始字节。 +- 他用户文件 → 404(不泄露存在性)。 +- `offset`/`maxBytes` 切片正确。 + +**`agent.useSkill` 拦截(集成)** + +- LLM 调 `useSkill("organizeByType")` → 后续 `moveFile` 允许,`deleteFile` 仅白名单含才允许。 +- LLM 调 `useSkill("none")` → 全只读探索。 +- `useSkill` 未知 key → 结构化错误给 LLM,planning 继续。 +- execute 期间 `useSkill` → 不可达(不在 execute 工具集);断言其被排除。 +- `prefer_skill_id` 设置 → 自动绑定,无需 `useSkill`;现有测试通过。 + +**端到端(扩展现有测试套件)** + +- `test_agent_plan_execute_runtime.py`:`allow_file_content=true` 下用 `readFile` 的 plan 成功;`false` 下 action 在 execute 被拒,job 报告之。 +- `test_agent_routes.py`:`data_policy.allow_file_content=true` 的 plan request 流通;setting 默认 `false` 覆盖之。 +- 有候选时 plan 输出含 skill 菜单;`chosen_skill` 反映 LLM 的 `useSkill` 选择。 + +**回归** + +- 所有现有 agent 测试不变通过(`prefer_skill_id` 路径与无 skill 路径保留)。 +- `policy.py` 重写:现有风险分类测试仍通过,因 `classify_tool_risk`/`classify_tool_side_effect` 保留(现支撑 `EffectivePermission` 计算)。 + +## 滚动出场 + +单 PR,**零 schema migration**(`AgentActionLog.status` 已核对为 `String(20)` 自由文本,加 `"denied"` 值无需 migration;见「数据模型」节)。可能附一个 seed migration 预置 3 个 builtin skill 行(或改用幂等启动 seed,二选一,实现期定)。无 feature flag——agent 子系统无第三方 API 消费者(见前序 spec 兼容性说明),且 request 级向后兼容(新 `dataPolicy` 字段已安全默认;`useSkill` 是叠加)。 + +PR 内顺序: + +1. `PermissionResolver` + 拓宽的 `PolicyGuard`(暂无行为变更——planner 仍可经薄 adapter 调旧签名,或一步切两个调用点)。 +2. `drive.readFile` 工具 + 内容门控。 +3. `agent.useSkill` 拦截 + skill 菜单注入。 +4. `AgentUserSetting` 默认值接线。 +5. 各自测试。 + +## 非目标(本轮) + +- **不做 `writeFile`**——仅 readFile。 +- **不做 OCR / 图片 / 音频内容分析**——二进制守卫仅返回元数据。 +- **不做 skill `plan_template_json` 执行**——模板保持信息性;LLM 得之作指引,runner 不模板化执行。 +- **不做 per-user 工具允许/拒绝表 / RBAC / 角色驱动策略**——选了三轴求交模型;per-user 粒度仅经 `AgentUserSetting` dataPolicy 默认。 +- **不做 per-skill 风险覆盖。** +- **不做 MCP 工具注册进 runtime**——`AgentMcpServer` 保持仅 CRUD(同今天)。 +- **不做 memory 注入 prompt**——`harness/memory.py` 保持 scaffold。 +- **不做 subagent runner**——`subagent_runner.py` 保持 scaffold。 +- **不做 staging/commit/rollback**——写仍直接打真实 folder(现有行为)。 +- **不做 budget/cost/checkpoint**——那些 harness scaffold 保持未建。 +- **不做 BM25→embedding skill 检索**——保留现有 keyword 评分,仅返回 top-K。 + +## 风险 + +- **`useSkill` 循环中变更**:LLM 看过完整菜单后收窄 `allowed_tools` 是安全的(交集只缩不放),但 LLM 可能在绑定前调用它*以为*允许的工具。planner 的逐调用 `PolicyGuard.evaluate(phase="planning")` 捕获之并返回结构化 blocked 结果——LLM 反应。显式测试。 +- **`readFile` 成本/延迟**:1 MiB 读进 LLM context 昂贵。由 `max_read_bytes` 默认 1 MiB、per-call `maxBytes` 默认 256 KiB、二进制守卫缓解。无流式。 +- **`AgentActionLog.status` enum**:已核对为 `String(20)` 自由文本,加 `denied` 无需 migration。 + +## 跨文件影响矩阵 + +| 文件 / 模块 | 改动 | +|---|---| +| `agents/harness/permission.py` | 新:`PermissionResolver` + `EffectivePermission` | +| `agents/harness/policy.py` | 重写:`PolicyGuard.evaluate` 拓宽为接 `EffectivePermission` + phase;保留 `classify_tool_*` | +| `agents/harness/skill_tool.py` | 新:`agent.useSkill` meta-tool 注册 + 拦截逻辑 | +| `agents/tools/drive.py` | + `drive.readFile` 工具 + handler;`ToolContext` 增 `storage_reader` | +| `agents/harness/tool_registry.py` | `ToolContext` 增 `storage_reader: MinioObjectStorageClient` 字段 | +| `agents/runtime/plan_runner.py` | `_choose_skill` → `_candidate_skills`(top-K);`_planning_tool_executor` 委托 `PolicyGuard` + 拦截 `useSkill`;接 `AgentUserSetting` 默认 | +| `agents/runtime/execute_runner.py` | per-step `PolicyGuard.evaluate(phase="executing")`;denied 写 `ActionLog` + `tool.failed` | +| `services/agent/plan_service.py` | (可选)早期加载 `AgentUserSetting` 传 runner | +| `core/settings.py` | + `AGENT_READ_FILE_MAX_BYTES`、`AGENT_SKILL_CANDIDATE_K` | +| `docker/flyway/migrations/` | 预置 3 个 builtin skill 的 seed migration(若不用启动 seed) | +| `app/tests/test_agent_plan_execute_runtime.py` | 扩展 readFile/dataPolicy/useSkill 用例 | +| `app/tests/test_agent_routes.py` | 扩展 dataPolicy 流通 + setting 覆盖用例 | + +## 兼容与迁移 + +- **后端 API**:不要求向后兼容——agent 子系统无公开第三方 API 消费者。`dataPolicy` 字段已存在且安全默认;`useSkill` 是叠加。 +- **数据库迁移**:**零 schema migration**(`AgentActionLog.status` 为 `String(20)`)。仅可能一个 seed migration 预置 3 个 builtin skill 行。 +- **配置项**:新增 `AGENT_READ_FILE_MAX_BYTES`(默认 1048576)、`AGENT_SKILL_CANDIDATE_K`(默认 3)。 diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index 5938f0b..6cf46aa 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -1,16 +1,63 @@ import http from '../utils/http'; import { useUserStore } from '../store/user'; import type { + AgentChatSessionDetail, + AgentChatSessionItem, + AgentChatSessionList, AgentBackgroundJob, + AttachAgentJobsRequest, + AttachAgentJobsResponse, + CreateAgentChatSessionRequest, AgentInboxMessageRequest, AgentInboxMessageResponse, AgentJobEvent, ExecuteAgentRequest, ExecuteAgentResponse, + PatchAgentChatSessionRequest, PlanAgentRequest, PlanAgentResponse, } from '../types/agent'; +export const createAgentChatSession = (data: CreateAgentChatSessionRequest = {}) => { + return http.post('/agent/chat-sessions', data); +}; + +export const listAgentChatSessions = (params: { page?: number; perPage?: number } = {}) => { + return http.get('/agent/chat-sessions', params); +}; + +export const getAgentChatSession = (chatSessionId: string) => { + return http.get( + `/agent/chat-sessions/${encodeURIComponent(chatSessionId)}`, + ); +}; + +export const patchAgentChatSession = ( + chatSessionId: string, + data: PatchAgentChatSessionRequest, +) => { + return http.patch( + `/agent/chat-sessions/${encodeURIComponent(chatSessionId)}`, + data, + ); +}; + +export const deleteAgentChatSession = (chatSessionId: string) => { + return http.delete( + `/agent/chat-sessions/${encodeURIComponent(chatSessionId)}`, + ); +}; + +export const attachAgentChatSessionJobs = ( + chatSessionId: string, + data: AttachAgentJobsRequest, +) => { + return http.post( + `/agent/chat-sessions/${encodeURIComponent(chatSessionId)}/attach-jobs`, + data, + ); +}; + export const planAgentTask = (data: PlanAgentRequest) => { return http.post('/agent/plan', data); }; @@ -41,14 +88,14 @@ export const pauseAgentJob = (jobId: string) => export const resumeAgentJob = (jobId: string) => sendAgentMessage(jobId, { kind: 'control.resume' }); -export const approveAgentStep = (jobId: string) => - sendAgentMessage(jobId, { kind: 'control.approve' }); +export const approveAgentStep = (jobId: string, step: number) => + sendAgentMessage(jobId, { kind: 'control.approve', metadata: { step } }); -export const denyAgentStep = (jobId: string) => - sendAgentMessage(jobId, { kind: 'control.deny' }); +export const denyAgentStep = (jobId: string, step: number, reason?: string) => + sendAgentMessage(jobId, { kind: 'control.deny', metadata: { step, ...(reason ? { reason } : {}) } }); -export const skipAgentStep = (jobId: string) => - sendAgentMessage(jobId, { kind: 'control.skip' }); +export const skipAgentStep = (jobId: string, step: number) => + sendAgentMessage(jobId, { kind: 'control.skip', metadata: { step } }); export const cancelAgentTurn = (jobId: string) => sendAgentMessage(jobId, { kind: 'control.cancel' }); diff --git a/web/src/components/organisms/agent/ControlBar.vue b/web/src/components/organisms/agent/ControlBar.vue index 46836c8..5c19b0f 100644 --- a/web/src/components/organisms/agent/ControlBar.vue +++ b/web/src/components/organisms/agent/ControlBar.vue @@ -5,7 +5,7 @@ import type { MsgStatus } from '../../../composables/useAgentSession'; defineProps<{ status: MsgStatus; - hasPlanRiskStep?: boolean; + hasApprovableStep?: boolean; }>(); defineEmits<{ @@ -27,6 +27,7 @@ const t = localeStore.t; v-if="status === 'running'" variant="ghost" size="sm" + :title="t('agent.v2.turn.controls.boundaryHint')" @click.stop="$emit('pause')" > {{ t('agent.v2.turn.controls.pause') }} @@ -45,16 +46,27 @@ const t = localeStore.t; v-if="status === 'running' || status === 'paused'" variant="ghost" size="sm" + :title="t('agent.v2.turn.controls.boundaryHint')" @click.stop="$emit('skip')" > {{ t('agent.v2.turn.controls.skip') }} -