From 8c0181b7e5d266af8a66cf1db61a7c088f4b7391 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:36:36 +0800 Subject: [PATCH 01/15] feat(agent): add agent schemas, API routes, and layer init exports --- app/src/fileflash/agents/__init__.py | 3 +- app/src/fileflash/agents/runtime/__init__.py | 4 +- app/src/fileflash/routers/__init__.py | 2 + app/src/fileflash/routers/agent.py | 87 ++++++++++- app/src/fileflash/schemas/__init__.py | 32 ++++ app/src/fileflash/schemas/agent.py | 145 +++++++++++++++++++ 6 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 app/src/fileflash/schemas/agent.py diff --git a/app/src/fileflash/agents/__init__.py b/app/src/fileflash/agents/__init__.py index b381960..98fd12d 100644 --- a/app/src/fileflash/agents/__init__.py +++ b/app/src/fileflash/agents/__init__.py @@ -14,9 +14,10 @@ ToolCall, ToolRouter, ) -from .runtime import ExecuteRunner, PlanRunner, SubagentRunner +from .runtime import AgentJobCanceled, ExecuteRunner, PlanRunner, SubagentRunner __all__ = [ + "AgentJobCanceled", "AgentEvent", "CheckpointStore", "ContextBudget", diff --git a/app/src/fileflash/agents/runtime/__init__.py b/app/src/fileflash/agents/runtime/__init__.py index ff41693..16eafa0 100644 --- a/app/src/fileflash/agents/runtime/__init__.py +++ b/app/src/fileflash/agents/runtime/__init__.py @@ -1,5 +1,5 @@ -from .execute_runner import ExecuteRunner +from .execute_runner import AgentJobCanceled, ExecuteRunner from .plan_runner import PlanRunner from .subagent_runner import SubagentRunner -__all__ = ["ExecuteRunner", "PlanRunner", "SubagentRunner"] +__all__ = ["AgentJobCanceled", "ExecuteRunner", "PlanRunner", "SubagentRunner"] diff --git a/app/src/fileflash/routers/__init__.py b/app/src/fileflash/routers/__init__.py index 3aeec2f..d73b994 100644 --- a/app/src/fileflash/routers/__init__.py +++ b/app/src/fileflash/routers/__init__.py @@ -17,6 +17,7 @@ from .shares import router as shares_router from .storage import router as storage_router from .uploads import router as uploads_router +from .agent import router as agent_router from .agent_skills import router as agent_skills_router api_router = APIRouter() @@ -37,6 +38,7 @@ api_router.include_router(shares_router) api_router.include_router(storage_router) api_router.include_router(uploads_router) +api_router.include_router(agent_router) api_router.include_router(agent_skills_router) __all__ = ["api_router"] diff --git a/app/src/fileflash/routers/agent.py b/app/src/fileflash/routers/agent.py index 46216dc..218976a 100644 --- a/app/src/fileflash/routers/agent.py +++ b/app/src/fileflash/routers/agent.py @@ -1,9 +1,90 @@ from __future__ import annotations -from fastapi import APIRouter +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import get_agent_execute_service, get_agent_plan_service, get_current_user +from ..core.errors import ApiError, api_success +from ..db.deps import get_db +from ..models import BackgroundJob +from ..models.tables_identity import User +from ..schemas.agent import CancelAgentResponse, ExecuteAgentRequest, PlanAgentRequest +from ..services.agent import ExecuteService, PlanService -# The agent router is intentionally scaffold-only for this stage. -# Do not include it in api_router until runtime/services are implemented. router = APIRouter(prefix="/agent", tags=["agent"]) + +@router.post("/plan") +async def plan_agent_task( + payload: PlanAgentRequest, + current_user: Annotated[User, Depends(get_current_user)], + plan_service: Annotated[PlanService, Depends(get_agent_plan_service)], +): + data = await plan_service.enqueue_plan(user_id=current_user.user_id, payload=payload) + return api_success( + data=data.model_dump(by_alias=True), + message="Plan job created", + ) + + +@router.post("/execute") +async def execute_agent_plan( + payload: ExecuteAgentRequest, + current_user: Annotated[User, Depends(get_current_user)], + execute_service: Annotated[ExecuteService, Depends(get_agent_execute_service)], +): + data = await execute_service.enqueue_execute(user_id=current_user.user_id, payload=payload) + return api_success( + data=data.model_dump(by_alias=True), + message="Execute job created", + ) + + +@router.post("/cancel/{job_id}") +async def cancel_agent_job( + job_id: str, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + try: + parsed_job_id = int(job_id) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message="Invalid jobId") from exc + job = await db.scalar( + select(BackgroundJob) + .where( + and_( + BackgroundJob.job_id == parsed_job_id, + BackgroundJob.requested_by == current_user.user_id, + BackgroundJob.task_type.in_(["agent.plan", "agent.execute"]), + ) + ) + .with_for_update() + ) + if job is None: + raise ApiError(status_code=404, code=404, message="Job not found") + + canceled_at = datetime.now(UTC) + if job.status not in {"succeeded", "failed", "canceled"}: + job.cancel_requested_at = canceled_at + job.updated_at = canceled_at + if job.status in {"pending", "retrying"}: + job.status = "canceled" + job.agent_phase = "canceled" + job.finished_at = canceled_at + await db.commit() + await db.refresh(job) + + data = CancelAgentResponse( + job_id=str(job.job_id), + status=str(job.status), + canceled_at=job.cancel_requested_at or canceled_at, + ) + return api_success(data=data.model_dump(by_alias=True), message="Job canceled") + + __all__ = ["router"] diff --git a/app/src/fileflash/schemas/__init__.py b/app/src/fileflash/schemas/__init__.py index 8fdbb94..c970aae 100644 --- a/app/src/fileflash/schemas/__init__.py +++ b/app/src/fileflash/schemas/__init__.py @@ -18,6 +18,23 @@ ListAgentSkillsQuery, UpdateAgentSkillRequest, ) +from .agent import ( + AgentApproval, + AgentChosenSkill, + AgentCostEstimate, + AgentDataPolicy, + AgentExecutionResult, + AgentHints, + AgentPlanContext, + AgentPlanResult, + AgentProposedAction, + AgentReasoningEffort, + CancelAgentResponse, + ExecuteAgentRequest, + ExecuteAgentResponse, + PlanAgentRequest, + PlanAgentResponse, +) from .common import ApiResponse, CamelModel, PageQuery, PaginatedData, PaginationMeta from .admin.files import ( AdminFileAuditDetail, @@ -155,6 +172,16 @@ "AccessUrls", "AcceptSharedItemResponse", "ActivityItem", + "AgentApproval", + "AgentChosenSkill", + "AgentCostEstimate", + "AgentDataPolicy", + "AgentExecutionResult", + "AgentHints", + "AgentPlanContext", + "AgentPlanResult", + "AgentProposedAction", + "AgentReasoningEffort", "AddGroupMemberRequest", "AddGroupMemberResponse", "AdminFileAuditDetail", @@ -169,6 +196,7 @@ "BroadcastNotificationRequest", "BreakdownDetail", "CamelModel", + "CancelAgentResponse", "ChangePasswordRequest", "ClearRecycleBinResponse", "ContentItem", @@ -184,6 +212,8 @@ "DeleteNotificationResponse", "DeletePermissionResponse", "DeleteShareResponse", + "ExecuteAgentRequest", + "ExecuteAgentResponse", "FileDetails", "FileItem", "FilterSummary", @@ -225,6 +255,8 @@ "PaginatedData", "PaginationMeta", "PermissionItem", + "PlanAgentRequest", + "PlanAgentResponse", "PermanentDeleteResponse", "CreateRegistrationEmailDomainRuleRequest", "RateLimitRule", diff --git a/app/src/fileflash/schemas/agent.py b/app/src/fileflash/schemas/agent.py new file mode 100644 index 0000000..96ce47b --- /dev/null +++ b/app/src/fileflash/schemas/agent.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import Field + +from .common import CamelModel + +AgentExecutionPolicy = Literal["planOnly", "confirm", "autopilot"] +AgentActionSideEffect = Literal["read", "write"] +AgentRiskLevel = Literal["low", "medium", "high"] +AgentReasoningEffort = Literal["adaptive", "low", "medium", "high", "xhigh", "max"] +AgentJobPhase = Literal[ + "planning", + "awaiting_confirm", + "executing", + "awaiting_commit", + "completed", + "failed", + "canceled", +] + + +class AgentDataPolicy(CamelModel): + allow_file_content: bool = False + max_read_bytes: int = Field(default=1_048_576, ge=0) + allowed_mime_types: list[str] = Field(default_factory=lambda: ["*/*"]) + + +class AgentHints(CamelModel): + prefer_skill_id: str | None = None + max_steps: int = Field(default=12, ge=1, le=100) + budget_tokens: int = Field(default=8_000, ge=1) + reasoning_effort: AgentReasoningEffort = "adaptive" + + +class AgentPlanContext(CamelModel): + root_folder_id: str = "root" + selected_file_ids: list[str] = Field(default_factory=list) + selected_folder_ids: list[str] = Field(default_factory=list) + current_path: str = "/My Files" + + +class PlanAgentRequest(CamelModel): + input: str = Field(min_length=1, max_length=4_000) + context: AgentPlanContext + execution_policy: AgentExecutionPolicy = "confirm" + data_policy: AgentDataPolicy = Field(default_factory=AgentDataPolicy) + hints: AgentHints = Field(default_factory=AgentHints) + + +class PlanAgentResponse(CamelModel): + job_id: str + status: str + task_type: Literal["agent.plan"] = "agent.plan" + + +class AgentProposedAction(CamelModel): + step: int = Field(ge=1) + tool: str = Field(min_length=1, max_length=120) + input: dict[str, Any] = Field(default_factory=dict) + side_effect: AgentActionSideEffect + risk_level: AgentRiskLevel = "low" + requires_confirmation: bool = False + confirmation_reason: str | None = None + + +class AgentCostEstimate(CamelModel): + tokens: int = Field(ge=0) + tool_calls: int = Field(ge=0) + duration_sec_estimate: int = Field(ge=0) + + +class AgentChosenSkill(CamelModel): + id: str + name: str + + +class AgentPlanResult(CamelModel): + plan_job_id: str + plan_hash: str + chosen_skill: AgentChosenSkill | None = None + proposed_actions: list[AgentProposedAction] + summary: str + requires_confirmation: bool + cost_estimate: AgentCostEstimate + + +class AgentApproval(CamelModel): + confirmed_by: str + confirmed_at: datetime + high_risk_confirmed: bool = False + high_risk_confirmed_at: datetime | None = None + + +class ExecuteAgentRequest(CamelModel): + plan_job_id: str + plan_hash: str + approval: AgentApproval + + +class ExecuteAgentResponse(CamelModel): + job_id: str + status: str + 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 + summary: str + applied_actions: int = Field(ge=0) + skipped_actions: int = Field(ge=0) + warnings: list[str] = Field(default_factory=list) + finished_at: datetime + + +__all__ = [ + "AgentActionSideEffect", + "AgentApproval", + "AgentChosenSkill", + "AgentCostEstimate", + "AgentDataPolicy", + "AgentExecutionPolicy", + "AgentExecutionResult", + "AgentHints", + "AgentJobPhase", + "AgentPlanContext", + "AgentPlanResult", + "AgentProposedAction", + "AgentReasoningEffort", + "AgentRiskLevel", + "CancelAgentResponse", + "ExecuteAgentRequest", + "ExecuteAgentResponse", + "PlanAgentRequest", + "PlanAgentResponse", +] From 2bb11714e47eb1300b254f26872cfe237537059c Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:36:54 +0800 Subject: [PATCH 02/15] feat(agent): implement plan_runner and execute_runner runtime - PlanRunner: orchestrates LLM planning with skill selection, context metadata collection, action normalization, plan hashing, and DB upsert - ExecuteRunner: executes planned actions via ToolRouter with policy guard, step reference resolution, action logging, and cancellation support --- .../agents/runtime/execute_runner.py | 193 ++++++- .../fileflash/agents/runtime/plan_runner.py | 544 +++++++++++++++++- 2 files changed, 733 insertions(+), 4 deletions(-) diff --git a/app/src/fileflash/agents/runtime/execute_runner.py b/app/src/fileflash/agents/runtime/execute_runner.py index ae932c5..60dcab2 100644 --- a/app/src/fileflash/agents/runtime/execute_runner.py +++ b/app/src/fileflash/agents/runtime/execute_runner.py @@ -1,6 +1,195 @@ from __future__ import annotations +import re +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models import BackgroundJob +from ...repositories import ( + AgentActionLogRepository, + AgentPlanRepository, + AgentWorkSessionRepository, +) +from ...schemas.agent import AgentExecutionResult, AgentProposedAction, ExecuteAgentRequest +from ..harness.policy import PolicyGuard +from ..harness.router import ToolCall, ToolRouter + + +class AgentJobCanceled(Exception): + pass + class ExecuteRunner: - async def run(self, *args, **kwargs): - raise NotImplementedError("ExecuteRunner is scaffolded only in this stage") + def __init__(self, *, policy_guard: PolicyGuard | None = None) -> None: + self.policy_guard = policy_guard or PolicyGuard() + + async def run(self, *, db: AsyncSession, job: BackgroundJob) -> AgentExecutionResult: + if job.requested_by is None: + raise ApiError(status_code=400, code=400, message="Agent job is missing requestedBy") + request = ExecuteAgentRequest.model_validate(dict(job.payload or {})) + plan_job_id = _parse_job_id(request.plan_job_id) + plan_repo = AgentPlanRepository(db) + work_sessions = AgentWorkSessionRepository(db) + plan = await plan_repo.get_for_execute_binding( + job_id=plan_job_id, + user_id=int(job.requested_by), + plan_hash=request.plan_hash, + ) + if plan is None: + raise ApiError(status_code=409, code=409, message="Plan hash mismatch") + + actions = [ + AgentProposedAction.model_validate(item) + for item in (plan.proposed_actions_json or []) + ] + high_risk_confirmed = bool(request.approval.high_risk_confirmed) + router = ToolRouter(db=db, user_id=int(job.requested_by)) + action_logs = AgentActionLogRepository(db) + step_outputs: dict[int, dict[str, Any]] = {} + applied = 0 + warnings: list[str] = [] + await work_sessions.create_for_job( + job_id=int(job.job_id), + user_id=int(job.requested_by), + checkpoint_json={"planJobId": str(plan_job_id), "planHash": request.plan_hash}, + ) + await db.commit() + + for action in actions: + await db.refresh(job) + if job.cancel_requested_at is not None: + raise AgentJobCanceled() + + decision = await self.policy_guard.evaluate_tool_call( + tool_name=action.tool, + high_risk_confirmed=high_risk_confirmed, + ) + 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}, + ) + + started = datetime.now(UTC) + try: + resolved_input = _resolve_references(action.input, step_outputs) + except Exception as exc: + duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) + await action_logs.append_step( + job_id=int(job.job_id), + step_no=action.step, + tool_name=action.tool, + inputs_json=action.input, + status="failed", + started_at=started, + ) + await action_logs.finish_step( + job_id=int(job.job_id), + step_no=action.step, + outputs_json={}, + status="failed", + duration_ms=duration_ms, + error_message=f"{type(exc).__name__}: {exc}"[:2000], + ) + await db.commit() + raise + + await action_logs.append_step( + job_id=int(job.job_id), + step_no=action.step, + tool_name=action.tool, + inputs_json=resolved_input, + status="running", + started_at=started, + ) + await db.commit() + + try: + output = await router.dispatch( + ToolCall(tool_name=action.tool, arguments=resolved_input) + ) + except Exception as exc: + await db.rollback() + duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) + await action_logs.finish_step( + job_id=int(job.job_id), + step_no=action.step, + outputs_json={}, + status="failed", + duration_ms=duration_ms, + error_message=f"{type(exc).__name__}: {exc}"[:2000], + ) + await db.commit() + raise + + duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) + await action_logs.finish_step( + job_id=int(job.job_id), + step_no=action.step, + outputs_json=output, + status="succeeded", + duration_ms=duration_ms, + ) + await db.commit() + step_outputs[action.step] = output + applied += 1 + + skipped = max(0, len(actions) - applied) + if skipped: + warnings.append(f"{skipped} action(s) were skipped.") + await work_sessions.close_session(job_id=int(job.job_id), status="closed") + await db.commit() + return AgentExecutionResult( + plan_job_id=str(plan_job_id), + execute_job_id=str(job.job_id), + summary=f"Execution completed with {applied} applied action(s).", + applied_actions=applied, + skipped_actions=skipped, + warnings=warnings, + finished_at=datetime.now(UTC), + ) + + +def _parse_job_id(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message="Invalid planJobId") from exc + if value <= 0: + raise ApiError(status_code=400, code=400, message="Invalid planJobId") + return value + + +_STEP_REF = re.compile(r"^\$step(?P\d+)\.(?P[A-Za-z0-9_.-]+)$") + + +def _resolve_references(value: Any, step_outputs: dict[int, dict[str, Any]]) -> Any: + if isinstance(value, str): + match = _STEP_REF.match(value) + if not match: + return value + step = int(match.group("step")) + path = match.group("path").split(".") + current: Any = step_outputs.get(step) + for part in path: + if isinstance(current, dict): + current = current.get(part) + else: + current = None + if current is None: + raise ApiError( + status_code=409, + code=409, + message=f"Unable to resolve tool reference: {value}", + ) + return current + if isinstance(value, list): + return [_resolve_references(item, step_outputs) for item in value] + if isinstance(value, dict): + return {key: _resolve_references(item, step_outputs) for key, item in value.items()} + return value diff --git a/app/src/fileflash/agents/runtime/plan_runner.py b/app/src/fileflash/agents/runtime/plan_runner.py index 46a5918..bbbc9bc 100644 --- a/app/src/fileflash/agents/runtime/plan_runner.py +++ b/app/src/fileflash/agents/runtime/plan_runner.py @@ -1,6 +1,546 @@ from __future__ import annotations +import hashlib +import json +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...core.mime import resolve_file_mime_type +from ...core.settings import Settings, get_settings +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, + AgentCostEstimate, + AgentPlanResult, + AgentProposedAction, + PlanAgentRequest, +) +from ..harness.policy import classify_tool_side_effect, normalize_action_risk +from .llm import AnthropicPlannerClient, PlannerClient + +DEFAULT_AGENT_TOOLS = ( + "drive.listFolder", + "drive.createFolder", + "drive.moveFile", + "drive.moveFolder", + "drive.renameFile", + "drive.renameFolder", + "drive.deleteFile", + "drive.deleteFolder", +) + class PlanRunner: - async def run(self, *args, **kwargs): - raise NotImplementedError("PlanRunner is scaffolded only in this stage") + def __init__( + self, + *, + settings: Settings | None = None, + planner_client: PlannerClient | None = None, + ) -> None: + self.settings = settings or get_settings() + self.planner_client = planner_client or AnthropicPlannerClient(settings=self.settings) + + async def run(self, *, db: AsyncSession, job: BackgroundJob) -> AgentPlanResult: + if job.requested_by is None: + raise ApiError(status_code=400, code=400, message="Agent job is missing requestedBy") + + request = PlanAgentRequest.model_validate(dict(job.payload or {})) + user_id = int(job.requested_by) + skill = await _choose_skill( + db, + user_id=user_id, + task_input=request.input, + prefer_skill_id=request.hints.prefer_skill_id, + ) + metadata = await _collect_context_metadata(db, user_id=user_id, request=request) + allowed_tools = _skill_tool_whitelist(skill) + llm_payload = await self.planner_client.create_plan( + system_prompt=_system_prompt(), + user_prompt=_user_prompt( + request=request, + skill=skill, + allowed_tools=allowed_tools, + metadata=metadata, + ), + max_tokens=request.hints.budget_tokens, + reasoning_effort=request.hints.reasoning_effort, + ) + + actions = _normalize_actions( + llm_payload=llm_payload, + allowed_tools=allowed_tools, + max_steps=min(request.hints.max_steps, self.settings.agent_job_max_tool_calls), + ) + chosen_skill = _chosen_skill(skill) + summary = str( + llm_payload.get("summary") or f"Prepared {len(actions)} file action(s)." + ).strip() + if not summary: + summary = f"Prepared {len(actions)} file action(s)." + + requires_confirmation = ( + request.execution_policy != "autopilot" + or any(action.requires_confirmation for action in actions) + ) + cost_estimate = _cost_estimate(llm_payload=llm_payload, actions=actions, metadata=metadata) + plan_hash = _plan_hash( + chosen_skill=chosen_skill, + actions=actions, + summary=summary, + ) + result = AgentPlanResult( + plan_job_id=str(job.job_id), + plan_hash=plan_hash, + chosen_skill=chosen_skill, + proposed_actions=actions, + summary=summary, + requires_confirmation=requires_confirmation, + cost_estimate=cost_estimate, + ) + await _upsert_agent_plan( + db, + job=job, + request=request, + result=result, + ) + return result + + +async def _choose_skill( + db: AsyncSession, + *, + user_id: int, + task_input: str, + prefer_skill_id: str | None, +) -> AgentSkill | AgentSkillCatalogEntry | None: + 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 None + + normalized_input = task_input.lower() + best: tuple[int, AgentSkillCatalogEntry] | None = None + 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 + 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] + + +def _tokens(text: str) -> list[str]: + return [token.strip(" ,.;:!?,。;:!?") for token in text.split() if token.strip()] + + +def _skill_key(skill: AgentSkill | AgentSkillCatalogEntry | None) -> str | None: + if skill is None: + return None + return str(skill.skill_key) + + +def _skill_name(skill: AgentSkill | AgentSkillCatalogEntry | None) -> str | None: + if skill is None: + return 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: + return tuple(str(item) for item in raw if str(item).strip()) + return DEFAULT_AGENT_TOOLS + + +def _chosen_skill(skill: AgentSkill | AgentSkillCatalogEntry | None) -> AgentChosenSkill | None: + key = _skill_key(skill) + name = _skill_name(skill) + if not key or not name: + return None + return AgentChosenSkill(id=key, name=name) + + +async def _collect_context_metadata( + db: AsyncSession, + *, + user_id: int, + request: PlanAgentRequest, +) -> dict[str, Any]: + context = request.context + selected_file_ids = _parse_ids(context.selected_file_ids, "fileId") + selected_folder_ids = _parse_ids(context.selected_folder_ids, "folderId") + + files: list[File] = [] + folders: list[Folder] = [] + if selected_file_ids: + files = list( + await db.scalars( + select(File).where( + and_( + File.owner_id == user_id, + File.file_id.in_(selected_file_ids), + File.status == FileStatus.ACTIVE, + File.is_latest.is_(True), + ) + ) + ) + ) + if selected_folder_ids: + folders = list( + await db.scalars( + select(Folder).where( + and_( + Folder.owner_id == user_id, + Folder.folder_id.in_(selected_folder_ids), + Folder.status == FolderStatus.ACTIVE, + ) + ) + ) + ) + + scope = "selected" if selected_file_ids or selected_folder_ids else "currentFolder" + folder_id = await _resolve_folder_id(db, user_id=user_id, folder_id=context.root_folder_id) + if scope == "currentFolder": + folders = list( + await db.scalars( + select(Folder) + .where( + and_( + Folder.owner_id == user_id, + Folder.parent_folder_id == folder_id, + Folder.status == FolderStatus.ACTIVE, + ) + ) + .order_by(Folder.folder_name.asc()) + .limit(200) + ) + ) + files = list( + await db.scalars( + select(File) + .where( + and_( + File.owner_id == user_id, + File.folder_id == folder_id, + File.status == FileStatus.ACTIVE, + File.is_latest.is_(True), + ) + ) + .order_by(File.file_name.asc()) + .limit(200) + ) + ) + + return { + "scope": scope, + "currentPath": context.current_path, + "rootFolderId": str(folder_id), + "files": [_file_metadata(row) for row in files], + "folders": [_folder_metadata(row) for row in folders], + } + + +async def _resolve_folder_id(db: AsyncSession, *, user_id: int, folder_id: str | None) -> int: + if not folder_id or folder_id == "root": + root = await db.scalar( + select(Folder).where( + and_( + Folder.owner_id == user_id, + Folder.parent_folder_id.is_(None), + Folder.folder_type == FolderType.ROOT, + Folder.status == FolderStatus.ACTIVE, + ) + ) + ) + if root is None: + raise ApiError(status_code=404, code=404, message="Root folder not found") + return int(root.folder_id) + try: + parsed = int(folder_id) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message="Invalid rootFolderId") from exc + exists = await db.scalar( + select(Folder.folder_id).where( + and_( + Folder.folder_id == parsed, + Folder.owner_id == user_id, + Folder.status == FolderStatus.ACTIVE, + ) + ) + ) + if exists is None: + raise ApiError(status_code=404, code=404, message="Folder not found") + return parsed + + +def _parse_ids(raw_ids: list[str], field_name: str) -> list[int]: + parsed: list[int] = [] + seen: set[int] = set() + for raw in raw_ids: + if raw == "root": + continue + try: + value = int(raw) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message=f"Invalid {field_name}") from exc + if value <= 0 or value in seen: + continue + parsed.append(value) + seen.add(value) + return parsed + + +def _file_metadata(row: File) -> dict[str, Any]: + return { + "itemType": "file", + "id": str(row.file_id), + "name": row.file_name, + "size": int(row.file_size or 0), + "mimeType": resolve_file_mime_type( + mime_type=row.mime_type, + file_ext=row.file_ext, + file_name=row.file_name, + ), + "folderId": str(row.folder_id), + "createdAt": row.created_at.isoformat() if row.created_at else None, + "updatedAt": row.updated_at.isoformat() if row.updated_at else None, + } + + +def _folder_metadata(row: Folder) -> dict[str, Any]: + return { + "itemType": "folder", + "id": str(row.folder_id), + "name": row.folder_name, + "size": int(row.cached_size or 0), + "parentFolderId": str(row.parent_folder_id) if row.parent_folder_id else None, + "createdAt": row.created_at.isoformat() if row.created_at else None, + "updatedAt": row.updated_at.isoformat() if row.updated_at else None, + } + + +def _system_prompt() -> str: + return ( + "You are FileFlash Agent Planner. Return only JSON. " + "Plan file-organization actions using the provided tools and metadata. " + "Do not read or infer file contents. Deletions are high risk and must be explicit." + ) + + +def _user_prompt( + *, + request: PlanAgentRequest, + skill: AgentSkill | AgentSkillCatalogEntry | None, + allowed_tools: tuple[str, ...], + metadata: dict[str, Any], +) -> str: + payload = { + "task": request.input, + "executionPolicy": request.execution_policy, + "reasoningEffort": request.hints.reasoning_effort, + "dataPolicy": request.data_policy.model_dump(by_alias=True), + "skill": _skill_payload(skill), + "allowedTools": list(allowed_tools), + "toolSchemas": _tool_schemas(allowed_tools), + "fileMetadata": metadata, + "outputSchema": { + "summary": "string", + "proposedActions": [ + { + "step": "integer starting at 1", + "tool": "one of allowedTools", + "input": "object", + "sideEffect": "read or write", + } + ], + }, + } + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +def _skill_payload(skill: AgentSkill | AgentSkillCatalogEntry | None) -> dict[str, Any] | None: + if skill is None: + return None + if isinstance(skill, AgentSkill): + return { + "skillKey": skill.skill_key, + "name": skill.name, + "description": skill.description, + "triggersText": skill.triggers_text, + "planTemplate": skill.plan_template_json or {}, + } + return { + "skillKey": skill.skill_key, + "name": skill.name, + "description": skill.description, + "triggersText": skill.triggers_text, + "planTemplate": skill.plan_template_json or {}, + } + + +def _tool_schemas(allowed_tools: tuple[str, ...]) -> list[dict[str, Any]]: + descriptions = { + "drive.listFolder": "List direct folder contents by folderId.", + "drive.createFolder": "Create a folder under parentFolderId with name.", + "drive.moveFile": "Move fileId into targetFolderId.", + "drive.moveFolder": "Move folderId into targetParentId.", + "drive.renameFile": "Rename fileId to fileName.", + "drive.renameFolder": "Rename folderId to folderName.", + "drive.deleteFile": "Soft-delete fileId into recycle bin. High risk.", + "drive.deleteFolder": "Soft-delete folderId into recycle bin. High risk.", + } + return [{"tool": tool, "description": descriptions.get(tool, "")} for tool in allowed_tools] + + +def _normalize_actions( + *, + llm_payload: dict[str, Any], + allowed_tools: tuple[str, ...], + max_steps: int, +) -> list[AgentProposedAction]: + raw_actions = llm_payload.get("proposedActions", llm_payload.get("proposed_actions")) + if raw_actions is None: + raw_actions = llm_payload.get("actions") + if not isinstance(raw_actions, list): + raise ApiError(status_code=502, code=502, message="Agent plan JSON missing proposedActions") + if len(raw_actions) > max_steps: + raise ApiError(status_code=400, code=400, message="Agent plan exceeds maxSteps") + + allowed = set(allowed_tools) + normalized: list[AgentProposedAction] = [] + seen_steps: set[int] = set() + for index, raw_action in enumerate(raw_actions, start=1): + if not isinstance(raw_action, dict): + raise ApiError(status_code=502, code=502, message="Agent action must be an object") + tool = str(raw_action.get("tool") or raw_action.get("toolName") or "").strip() + if tool not in allowed: + raise ApiError( + status_code=400, + code=400, + message=f"Tool is not allowed by selected skill: {tool}", + ) + action_input = raw_action.get("input", raw_action.get("arguments", {})) + if not isinstance(action_input, dict): + raise ApiError( + status_code=502, + code=502, + message="Agent action input must be an object", + ) + try: + step = int(raw_action.get("step") or index) + except (TypeError, ValueError) as exc: + raise ApiError( + status_code=502, + code=502, + message="Agent action step is invalid", + ) from exc + if step < 1 or step in seen_steps: + raise ApiError(status_code=502, code=502, message="Agent action step is invalid") + seen_steps.add(step) + action = AgentProposedAction( + step=step, + tool=tool, + input=action_input, + side_effect=raw_action.get("sideEffect") or classify_tool_side_effect(tool), # type: ignore[arg-type] + risk_level=raw_action.get("riskLevel") or "low", # type: ignore[arg-type] + requires_confirmation=bool(raw_action.get("requiresConfirmation") or False), + confirmation_reason=raw_action.get("confirmationReason"), + ) + normalized.append(normalize_action_risk(action)) + return sorted(normalized, key=lambda action: action.step) + + +def _cost_estimate( + *, + llm_payload: dict[str, Any], + actions: list[AgentProposedAction], + metadata: dict[str, Any], +) -> AgentCostEstimate: + usage = llm_payload.get("_usage") + if isinstance(usage, dict): + input_tokens = int(usage.get("input_tokens") or 0) + output_tokens = int(usage.get("output_tokens") or 0) + tokens = input_tokens + output_tokens + else: + tokens = max(1000, len(json.dumps(metadata, ensure_ascii=False)) // 3 + len(actions) * 180) + return AgentCostEstimate( + tokens=tokens, + tool_calls=len(actions), + duration_sec_estimate=max(1, len(actions) * 3), + ) + + +def _plan_hash( + *, + chosen_skill: AgentChosenSkill | None, + actions: list[AgentProposedAction], + summary: str, +) -> str: + payload = { + "chosenSkill": chosen_skill.model_dump(by_alias=True) if chosen_skill else None, + "proposedActions": [action.model_dump(by_alias=True) for action in actions], + "summary": summary, + } + raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +async def _upsert_agent_plan( + db: AsyncSession, + *, + job: BackgroundJob, + request: PlanAgentRequest, + result: AgentPlanResult, +) -> None: + existing = await db.scalar(select(AgentPlan).where(AgentPlan.job_id == job.job_id)) + values = { + "job_id": int(job.job_id), + "user_id": int(job.requested_by or 0), + "input_text": request.input, + "context_json": request.context.model_dump(by_alias=True), + "execution_policy": DbAgentExecutionPolicy(request.execution_policy), + "data_policy_json": request.data_policy.model_dump(by_alias=True), + "chosen_skill_id": result.chosen_skill.id if result.chosen_skill else None, + "proposed_actions_json": [ + action.model_dump(by_alias=True) for action in result.proposed_actions + ], + "plan_hash": result.plan_hash, + "summary": result.summary, + "cost_estimate_json": result.cost_estimate.model_dump(by_alias=True), + "created_at": datetime.now(UTC), + } + if existing is None: + db.add(AgentPlan(**values)) + else: + for key, value in values.items(): + if key not in {"job_id", "created_at"}: + setattr(existing, key, value) + await db.flush() From cdf6f42e59f8d7ac07247900f816e7ed7a07cf57 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:37:05 +0800 Subject: [PATCH 03/15] feat(agent): implement policy guard and tool router harness - PolicyGuard: evaluate tool calls with risk classification, block unconfirmed high-risk actions - ToolRouter: dispatch drive.* tools (listFolder, createFolder, move/rename/delete file/folder) against real services - Add classify_tool_side_effect, classify_tool_risk, normalize_action_risk helpers --- app/src/fileflash/agents/harness/policy.py | 68 ++++++++++- app/src/fileflash/agents/harness/router.py | 132 ++++++++++++++++++++- 2 files changed, 197 insertions(+), 3 deletions(-) diff --git a/app/src/fileflash/agents/harness/policy.py b/app/src/fileflash/agents/harness/policy.py index 9e1e4e4..027af98 100644 --- a/app/src/fileflash/agents/harness/policy.py +++ b/app/src/fileflash/agents/harness/policy.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, field +from ...schemas.agent import AgentProposedAction + @dataclass(slots=True) class PolicyDecision: @@ -9,6 +11,68 @@ class PolicyDecision: reasons: list[str] = field(default_factory=list) +HIGH_RISK_TOOLS = frozenset( + { + "drive.deleteFile", + "drive.deleteFolder", + "drive.batchDelete", + "recycle.clear", + "recycle.permanentDelete", + } +) + +WRITE_TOOLS = frozenset( + { + "drive.createFolder", + "drive.moveFile", + "drive.moveFolder", + "drive.renameFile", + "drive.renameFolder", + *HIGH_RISK_TOOLS, + } +) + + +def classify_tool_side_effect(tool_name: str) -> str: + return "write" if tool_name in WRITE_TOOLS else "read" + + +def classify_tool_risk(tool_name: str) -> str: + if tool_name in HIGH_RISK_TOOLS or "delete" in tool_name.lower(): + return "high" + if classify_tool_side_effect(tool_name) == "write": + return "medium" + return "low" + + +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_tool_call(self, *args, **kwargs) -> PolicyDecision: - raise NotImplementedError("PolicyGuard is scaffolded only in this stage") + async def evaluate_tool_call( + self, + *, + tool_name: str, + high_risk_confirmed: bool = False, + ) -> PolicyDecision: + if classify_tool_risk(tool_name) == "high" and not high_risk_confirmed: + return PolicyDecision( + allowed=False, + reasons=["High-risk delete action requires explicit user confirmation."], + ) + return PolicyDecision(allowed=True) diff --git a/app/src/fileflash/agents/harness/router.py b/app/src/fileflash/agents/harness/router.py index 1a8b3d4..1878099 100644 --- a/app/src/fileflash/agents/harness/router.py +++ b/app/src/fileflash/agents/harness/router.py @@ -3,6 +3,20 @@ from dataclasses import dataclass from typing import Any +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...schemas.file import ( + CreateFolderRequest, + GetFolderContentsQuery, + MoveFileRequest, + MoveFolderRequest, + RenameFileRequest, + RenameFolderRequest, +) +from ...services.file import FileService +from ...services.folder import FolderService + @dataclass(slots=True) class ToolCall: @@ -11,5 +25,121 @@ class ToolCall: class ToolRouter: + def __init__(self, *, db: AsyncSession, user_id: int) -> None: + self.db = db + self.user_id = user_id + self.file_service = FileService(db=db) + self.folder_service = FolderService(db=db) + async def dispatch(self, call: ToolCall) -> dict[str, Any]: - raise NotImplementedError("ToolRouter is scaffolded only in this stage") + tool = call.tool_name + args = dict(call.arguments or {}) + + if tool == "drive.listFolder": + folder_id = _first_value(args, "folderId", "parentFolderId") or "root" + query = GetFolderContentsQuery( + folder_id=str(folder_id), + page=int(args.get("page") or 1), + per_page=min(200, int(args.get("perPage") or 200)), + ) + if str(folder_id) == "root": + result = await self.folder_service.get_root_contents( + user_id=self.user_id, + query=query, + ) + else: + result = await self.folder_service.get_folder_contents( + user_id=self.user_id, + query=query, + ) + return result.model_dump(by_alias=True) + + if tool == "drive.createFolder": + name = _required_text(args, "name", "folderName") + parent_id = _first_value(args, "parentFolderId", "targetParentId", "folderId") or "root" + result = await self.folder_service.create_folder( + user_id=self.user_id, + payload=CreateFolderRequest(folder_name=name, parent_folder_id=str(parent_id)), + ) + data = result.model_dump(by_alias=True) + data.setdefault("folderId", data.get("id")) + return data + + if tool == "drive.moveFile": + file_id = _required_text(args, "fileId", "id") + target_folder_id = _required_text(args, "targetFolderId", "targetParentId") + result = await self.file_service.move_file( + user_id=self.user_id, + file_id=file_id, + payload=MoveFileRequest( + target_folder_id=target_folder_id, + share_handling=str(args.get("shareHandling") or "keep"), + ), + ) + return result.model_dump(by_alias=True) + + if tool == "drive.moveFolder": + folder_id = _required_text(args, "folderId", "id") + target_parent_id = _required_text(args, "targetParentId", "targetFolderId") + result = await self.folder_service.move_folder( + user_id=self.user_id, + folder_id=folder_id, + payload=MoveFolderRequest( + target_parent_id=target_parent_id, + share_handling=str(args.get("shareHandling") or "keep"), + ), + ) + return result.model_dump(by_alias=True) + + if tool == "drive.renameFile": + file_id = _required_text(args, "fileId", "id") + file_name = _required_text(args, "fileName", "name") + result = await self.file_service.rename_file( + user_id=self.user_id, + file_id=file_id, + payload=RenameFileRequest(file_name=file_name), + ) + return result.model_dump(by_alias=True) + + if tool == "drive.renameFolder": + folder_id = _required_text(args, "folderId", "id") + folder_name = _required_text(args, "folderName", "name") + result = await self.folder_service.rename_folder( + user_id=self.user_id, + folder_id=folder_id, + payload=RenameFolderRequest(folder_name=folder_name), + ) + return result.model_dump(by_alias=True) + + if tool == "drive.deleteFile": + file_id = _required_text(args, "fileId", "id") + result = await self.file_service.delete_file(user_id=self.user_id, file_id=file_id) + return result.model_dump(by_alias=True) + + if tool == "drive.deleteFolder": + folder_id = _required_text(args, "folderId", "id") + result = await self.folder_service.delete_folder( + user_id=self.user_id, + folder_id=folder_id, + ) + return result.model_dump(by_alias=True) + + raise ApiError(status_code=400, code=400, message=f"Unsupported agent tool: {tool}") + + +def _first_value(args: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = args.get(key) + if value not in (None, ""): + return value + return None + + +def _required_text(args: dict[str, Any], *keys: str) -> str: + value = _first_value(args, *keys) + if value is None: + raise ApiError(status_code=400, code=400, message=f"Missing required tool input: {keys[0]}") + text = str(value).strip() + if not text: + raise ApiError(status_code=400, code=400, message=f"Missing required tool input: {keys[0]}") + return text From b93befc76170e9227d3c08ae02cd3853d2bc1d14 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:37:22 +0800 Subject: [PATCH 04/15] feat(agent): add LLM planner client and agent worker process - AnthropicPlannerClient: calls Anthropic API (compatible) for plan generation with system/user prompts - worker.py: background consumer that polls agent.plan/agent.execute jobs and delegates to runners --- app/src/fileflash/agents/runtime/llm.py | 169 ++++++++++++++++++ app/src/fileflash/agents/worker.py | 221 ++++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 app/src/fileflash/agents/runtime/llm.py create mode 100644 app/src/fileflash/agents/worker.py diff --git a/app/src/fileflash/agents/runtime/llm.py b/app/src/fileflash/agents/runtime/llm.py new file mode 100644 index 0000000..d4b157f --- /dev/null +++ b/app/src/fileflash/agents/runtime/llm.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +from typing import Any, Protocol + +import anthropic +from anthropic import AsyncAnthropic + +from ...core.errors import ApiError +from ...core.settings import Settings + + +class PlannerClient(Protocol): + async def create_plan( + self, + *, + system_prompt: str, + user_prompt: str, + max_tokens: int, + reasoning_effort: str = "adaptive", + ) -> dict[str, Any]: ... + + +class AnthropicPlannerClient: + def __init__(self, *, settings: Settings, client: AsyncAnthropic | None = None) -> None: + self.settings = settings + self._client = client + + async def create_plan( + self, + *, + system_prompt: str, + user_prompt: str, + max_tokens: int, + reasoning_effort: str = "adaptive", + ) -> dict[str, Any]: + api_key = (self.settings.agent_llm_api_key or "").strip() + if not api_key: + raise ApiError(status_code=503, code=503, message="Agent LLM API key is not configured") + + request_kwargs: dict[str, Any] = { + "model": self.settings.agent_llm_model, + "max_tokens": min(max_tokens, 4096), + "system": system_prompt, + "messages": [{"role": "user", "content": user_prompt}], + "timeout": 60.0, + } + request_kwargs.update(_reasoning_params(reasoning_effort)) + try: + message = await self._get_client(api_key).messages.create(**request_kwargs) + except anthropic.APIStatusError as exc: + raise ApiError( + status_code=503, + code=503, + message="Agent LLM request failed", + data={"status": exc.status_code, "details": _response_details(exc)}, + ) from exc + except ( + anthropic.APIConnectionError, + anthropic.APITimeoutError, + anthropic.APIError, + ) as exc: + raise ApiError( + status_code=503, + code=503, + message=f"Agent LLM request failed: {type(exc).__name__}", + ) from exc + + text = _extract_text(message) + parsed = _parse_json_text(text) + usage = _usage_payload(message) + if isinstance(usage, dict): + parsed["_usage"] = usage + return parsed + + def _get_client(self, api_key: str) -> AsyncAnthropic: + if self._client is None: + base_url = (self.settings.agent_llm_base_url or "").strip() or None + self._client = AsyncAnthropic( + api_key=api_key, + base_url=base_url, + timeout=60.0, + max_retries=0, + ) + return self._client + + +def _extract_text(message: Any) -> str: + chunks = getattr(message, "content", None) + if not isinstance(chunks, list): + raise ApiError(status_code=502, code=502, message="Agent LLM returned an invalid response") + parts: list[str] = [] + for chunk in chunks: + if isinstance(chunk, dict): + if chunk.get("type") == "text": + parts.append(str(chunk.get("text") or "")) + continue + if getattr(chunk, "type", None) == "text": + parts.append(str(getattr(chunk, "text", "") or "")) + text = "\n".join(part for part in parts if part).strip() + if not text: + raise ApiError(status_code=502, code=502, message="Agent LLM returned an empty response") + return text + + +def _usage_payload(message: Any) -> dict[str, Any] | None: + usage = getattr(message, "usage", None) + if usage is None: + return None + if isinstance(usage, dict): + return usage + if hasattr(usage, "model_dump"): + dumped = usage.model_dump() + return dumped if isinstance(dumped, dict) else None + payload: dict[str, Any] = {} + usage_token_fields = ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + ) + for key in usage_token_fields: + value = getattr(usage, key, None) + if value is not None: + payload[key] = value + return payload or None + + +def _reasoning_params(reasoning_effort: str) -> dict[str, Any]: + effort = (reasoning_effort or "adaptive").strip().lower() + if effort == "adaptive": + return {"thinking": {"type": "adaptive"}} + if effort not in {"low", "medium", "high", "xhigh", "max"}: + effort = "high" + return { + "thinking": {"type": "enabled"}, + "output_config": {"effort": effort}, + } + + +def _response_details(error: anthropic.APIStatusError) -> str: + response = getattr(error, "response", None) + text = getattr(response, "text", "") if response is not None else "" + return str(text or "")[:800] + + +def _parse_json_text(text: str) -> dict[str, Any]: + candidate = text.strip() + if candidate.startswith("```"): + lines = candidate.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].startswith("```"): + lines = lines[:-1] + candidate = "\n".join(lines).strip() + try: + parsed = json.loads(candidate) + except json.JSONDecodeError as exc: + raise ApiError( + status_code=502, + code=502, + message="Agent LLM did not return valid JSON", + ) from exc + if not isinstance(parsed, dict): + raise ApiError(status_code=502, code=502, message="Agent LLM JSON must be an object") + return parsed + + +__all__ = ["AnthropicPlannerClient", "PlannerClient"] diff --git a/app/src/fileflash/agents/worker.py b/app/src/fileflash/agents/worker.py new file mode 100644 index 0000000..871c214 --- /dev/null +++ b/app/src/fileflash/agents/worker.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import UTC, datetime +from typing import Any + +from fastapi.encoders import jsonable_encoder +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from ..core.errors import ApiError +from ..core.settings import get_settings +from ..db.session import SessionLocal +from ..db.transaction import apply_local_lock_timeout +from ..models import BackgroundJob +from ..services.job_queue import RedisStreamJobQueue +from ..workers.contracts import WorkerJobMessage +from .runtime import AgentJobCanceled, ExecuteRunner, PlanRunner + +logger = logging.getLogger(__name__) + + +class AgentWorkerConsumer: + def __init__( + self, + *, + queue: RedisStreamJobQueue, + session_factory: async_sessionmaker[AsyncSession] = SessionLocal, + ) -> None: + self._settings = get_settings() + self._queue = queue + self._session_factory = session_factory + + async def run(self) -> None: + logger.info( + "Agent worker started queue=%s group=%s concurrency=%s", + self._settings.agent_queue_stream, + self._settings.agent_queue_group, + self._settings.agent_worker_concurrency, + ) + async with asyncio.TaskGroup() as group: + for slot in range(max(1, self._settings.agent_worker_concurrency)): + group.create_task(self._run_slot(slot)) + + async def _run_slot(self, slot: int) -> None: + while True: + queued = await self._queue.consume_one(block_ms=self._settings.agent_queue_block_ms) + if queued is None: + continue + queue_message_id, message = queued + try: + await self._process_message(slot=slot, message=message) + finally: + await self._queue.ack(queue_message_id) + + async def _process_message(self, *, slot: int, message: WorkerJobMessage) -> None: + job = await self._mark_running(message) + if job is None: + return + + started = datetime.now(UTC) + try: + result, phase = await asyncio.wait_for( + self._run_job(job=job), + timeout=self._settings.agent_job_timeout_sec, + ) + except AgentJobCanceled: + await self._mark_canceled(job_id=message.job_id) + logger.info( + "Agent slot=%s canceled jobId=%s taskType=%s", + slot, + message.job_id, + message.task_type, + ) + return + except Exception as exc: + await self._mark_failed(job_id=message.job_id, error=exc) + logger.warning( + "Agent slot=%s failed jobId=%s taskType=%s error=%s", + slot, + message.job_id, + message.task_type, + exc, + ) + return + + await self._mark_succeeded(job_id=message.job_id, result=result, phase=phase) + duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) + logger.info( + "Agent slot=%s succeeded jobId=%s taskType=%s durationMs=%s traceId=%s", + slot, + message.job_id, + message.task_type, + duration_ms, + message.trace_id, + ) + + async def _run_job(self, *, job: BackgroundJob) -> tuple[dict[str, Any], str]: + async with self._session_factory() as db: + fresh_job = await db.get(BackgroundJob, int(job.job_id)) + if fresh_job is None: + raise ApiError(status_code=404, code=404, message="Job not found") + if fresh_job.task_type == "agent.plan": + result = await PlanRunner(settings=self._settings).run(db=db, job=fresh_job) + phase = "awaiting_confirm" if result.requires_confirmation else "completed" + return result.model_dump(by_alias=True), phase + if fresh_job.task_type == "agent.execute": + result = await ExecuteRunner().run(db=db, job=fresh_job) + return result.model_dump(by_alias=True), "completed" + raise ApiError( + status_code=400, + code=400, + message=f"Unsupported agent task: {fresh_job.task_type}", + ) + + async def _mark_running(self, message: WorkerJobMessage) -> BackgroundJob | None: + async with self._session_factory() as db: + async with db.begin(): + await apply_local_lock_timeout(db) + job = await db.scalar( + select(BackgroundJob) + .where(BackgroundJob.job_id == message.job_id) + .with_for_update() + ) + if job is None or job.status in {"succeeded", "failed", "canceled"}: + return None + now = datetime.now(UTC) + job.status = "running" + job.started_at = job.started_at or now + job.updated_at = now + job.error_message = None + job.agent_phase = "planning" if job.task_type == "agent.plan" else "executing" + return job + + async def _mark_succeeded(self, *, job_id: int, result: dict[str, Any], phase: str) -> None: + async with self._session_factory() as db: + async with db.begin(): + await apply_local_lock_timeout(db) + job = await db.scalar( + select(BackgroundJob).where(BackgroundJob.job_id == job_id).with_for_update() + ) + if job is None: + return + now = datetime.now(UTC) + job.status = "succeeded" + job.result = jsonable_encoder(result) + job.error_message = None + job.agent_phase = phase + job.finished_at = now + job.updated_at = now + + async def _mark_failed(self, *, job_id: int, error: Exception) -> None: + message = _error_message(error) + async with self._session_factory() as db: + async with db.begin(): + await apply_local_lock_timeout(db) + job = await db.scalar( + select(BackgroundJob).where(BackgroundJob.job_id == job_id).with_for_update() + ) + if job is None: + return + now = datetime.now(UTC) + job.status = "failed" + job.agent_phase = "failed" + job.error_message = message[:2000] + job.finished_at = now + job.updated_at = now + + async def _mark_canceled(self, *, job_id: int) -> None: + async with self._session_factory() as db: + async with db.begin(): + await apply_local_lock_timeout(db) + job = await db.scalar( + select(BackgroundJob).where(BackgroundJob.job_id == job_id).with_for_update() + ) + if job is None: + return + now = datetime.now(UTC) + job.status = "canceled" + job.agent_phase = "canceled" + job.cancel_requested_at = job.cancel_requested_at or now + job.finished_at = now + job.updated_at = now + + +def _error_message(error: Exception) -> str: + if isinstance(error, ApiError): + return f"ApiError[{error.status_code}/{error.code}]: {error.message}" + return f"{type(error).__name__}: {error}" + + +async def run_agent_worker() -> None: + settings = get_settings() + queue = RedisStreamJobQueue( + redis_url=settings.redis_url, + stream_key=settings.agent_queue_stream, + group_name=settings.agent_queue_group, + consumer_name=f"agent-{uuid.uuid4().hex[:8]}", + ) + consumer = AgentWorkerConsumer(queue=queue) + try: + await consumer.run() + finally: + await queue.aclose() + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) + try: + asyncio.run(run_agent_worker()) + except KeyboardInterrupt: + logger.info("Agent worker stopped by keyboard interrupt") + + +if __name__ == "__main__": + main() From b4c59163f5db97a41439f1ac9174077a1a297cdc Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:37:34 +0800 Subject: [PATCH 05/15] feat(agent): add plan and execute services with background job support - PlanService: enqueue plan jobs with rate-limit enforcement, validate token/steps budgets - ExecuteService: enqueue execute jobs with plan-hash verification and high-risk confirmation check - BackgroundJobService: add agent_phase column support to job enqueue --- .../services/agent/execute_service.py | 88 ++++++++++++++++++- .../fileflash/services/agent/plan_service.py | 68 +++++++++++++- app/src/fileflash/services/background_jobs.py | 2 + 3 files changed, 152 insertions(+), 6 deletions(-) diff --git a/app/src/fileflash/services/agent/execute_service.py b/app/src/fileflash/services/agent/execute_service.py index b2031c4..83f498f 100644 --- a/app/src/fileflash/services/agent/execute_service.py +++ b/app/src/fileflash/services/agent/execute_service.py @@ -1,8 +1,14 @@ from __future__ import annotations +from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession +from ...agents.harness.policy import classify_tool_risk +from ...core.errors import ApiError +from ...core.settings import Settings +from ...models import BackgroundJob from ...repositories import AgentPlanRepository, AgentWorkSessionRepository +from ...schemas.agent import AgentProposedAction, ExecuteAgentRequest, ExecuteAgentResponse from ..background_jobs import BackgroundJobService @@ -11,14 +17,92 @@ def __init__( self, *, db: AsyncSession, + settings: Settings, jobs: BackgroundJobService, plans: AgentPlanRepository, work_sessions: AgentWorkSessionRepository, ) -> None: self.db = db + self.settings = settings self.jobs = jobs self.plans = plans self.work_sessions = work_sessions - async def enqueue_execute(self, *args, **kwargs): - raise NotImplementedError("Agent execute service is scaffolded only in this stage") + async def enqueue_execute( + self, + *, + user_id: int, + payload: ExecuteAgentRequest, + ) -> ExecuteAgentResponse: + if not self.settings.agent_enabled: + raise ApiError(status_code=503, code=503, message="Agent runtime is disabled") + + plan_job_id = _parse_job_id(payload.plan_job_id) + plan_job = await self.db.scalar( + select(BackgroundJob).where( + and_( + BackgroundJob.job_id == plan_job_id, + BackgroundJob.requested_by == user_id, + BackgroundJob.task_type == "agent.plan", + ) + ) + ) + if plan_job is None: + raise ApiError(status_code=404, code=404, message="Plan job not found") + if plan_job.status != "succeeded": + raise ApiError(status_code=409, code=409, message="Plan job is not ready for execution") + + plan = await self.plans.get_for_execute_binding( + job_id=plan_job_id, + user_id=user_id, + plan_hash=payload.plan_hash, + ) + if plan is None: + raise ApiError(status_code=409, code=409, message="planHash mismatch") + + high_risk_actions = _high_risk_actions(plan.proposed_actions_json or []) + if high_risk_actions and not payload.approval.high_risk_confirmed: + raise ApiError( + status_code=409, + code=409, + message="High-risk action requires confirmation", + data={"highRiskActions": high_risk_actions}, + ) + + job = await self.jobs.enqueue( + self.db, + task_type="agent.execute", + payload=payload.model_dump(by_alias=True), + requested_by=user_id, + max_attempts=1, + priority=100, + agent_phase="executing", + ) + return ExecuteAgentResponse( + job_id=str(job.job_id), + status=str(job.status), + task_type="agent.execute", + ) + + +def _parse_job_id(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise ApiError(status_code=400, code=400, message="Invalid planJobId") from exc + if value <= 0: + raise ApiError(status_code=400, code=400, message="Invalid planJobId") + return value + + +def _high_risk_actions(raw_actions: object) -> list[dict[str, object]]: + if not isinstance(raw_actions, list): + return [] + risky: list[dict[str, object]] = [] + for item in raw_actions: + if not isinstance(item, dict): + continue + action = AgentProposedAction.model_validate(item) + if action.risk_level == "high" or classify_tool_risk(action.tool) == "high": + risky.append(action.model_dump(by_alias=True)) + return risky diff --git a/app/src/fileflash/services/agent/plan_service.py b/app/src/fileflash/services/agent/plan_service.py index 94f14d0..4ab7994 100644 --- a/app/src/fileflash/services/agent/plan_service.py +++ b/app/src/fileflash/services/agent/plan_service.py @@ -1,8 +1,15 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta + +from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession +from ...core.errors import ApiError +from ...core.settings import Settings +from ...models import BackgroundJob from ...repositories import AgentPlanRepository, AgentSettingsRepository, AgentWorkSessionRepository +from ...schemas.agent import PlanAgentRequest, PlanAgentResponse from ..background_jobs import BackgroundJobService @@ -11,16 +18,69 @@ def __init__( self, *, db: AsyncSession, + settings: Settings, jobs: BackgroundJobService, plans: AgentPlanRepository, - settings: AgentSettingsRepository, + settings_repo: AgentSettingsRepository, work_sessions: AgentWorkSessionRepository, ) -> None: self.db = db + self.settings = settings self.jobs = jobs self.plans = plans - self.settings = settings + self.settings_repo = settings_repo self.work_sessions = work_sessions - async def enqueue_plan(self, *args, **kwargs): - raise NotImplementedError("Agent plan service is scaffolded only in this stage") + async def enqueue_plan(self, *, user_id: int, payload: PlanAgentRequest) -> PlanAgentResponse: + if not self.settings.agent_enabled: + raise ApiError(status_code=503, code=503, message="Agent runtime is disabled") + if payload.hints.budget_tokens > self.settings.agent_job_max_tokens: + raise ApiError( + status_code=400, + code=400, + message="Agent token budget exceeds server limit", + ) + if payload.hints.max_steps > self.settings.agent_job_max_tool_calls: + raise ApiError(status_code=400, code=400, message="Agent maxSteps exceeds server limit") + + await self._enforce_limits(user_id=user_id) + job = await self.jobs.enqueue( + self.db, + task_type="agent.plan", + payload=payload.model_dump(by_alias=True), + requested_by=user_id, + max_attempts=1, + priority=100, + agent_phase="planning", + ) + return PlanAgentResponse( + job_id=str(job.job_id), + status=str(job.status), + task_type="agent.plan", + ) + + async def _enforce_limits(self, *, user_id: int) -> None: + concurrent = await self.db.scalar( + select(func.count(BackgroundJob.job_id)).where( + and_( + BackgroundJob.requested_by == user_id, + BackgroundJob.task_type.in_(["agent.plan", "agent.execute"]), + BackgroundJob.status.in_(["pending", "running", "retrying"]), + ) + ) + ) + if int(concurrent or 0) >= self.settings.agent_user_concurrent_limit: + raise ApiError(status_code=429, code=429, message="Agent concurrent job limit exceeded") + + since = datetime.now(UTC) - timedelta(days=1) + daily = await self.db.scalar( + select(func.count(BackgroundJob.job_id)).where( + and_( + BackgroundJob.requested_by == user_id, + BackgroundJob.task_type == "agent.plan", + BackgroundJob.created_at >= since, + ) + ) + ) + if int(daily or 0) >= self.settings.agent_user_daily_limit: + raise ApiError(status_code=429, code=429, message="Agent daily job limit exceeded") diff --git a/app/src/fileflash/services/background_jobs.py b/app/src/fileflash/services/background_jobs.py index 4ad5147..3b86a6b 100644 --- a/app/src/fileflash/services/background_jobs.py +++ b/app/src/fileflash/services/background_jobs.py @@ -29,6 +29,7 @@ async def enqueue( requested_by: int | None = None, max_attempts: int = 5, priority: int = 100, + agent_phase: str | None = None, ) -> BackgroundJob: now = datetime.now(UTC) job = BackgroundJob( @@ -42,6 +43,7 @@ async def enqueue( scheduled_at=now, trace_id=str(uuid.uuid4()), idempotency_key=idempotency_key, + agent_phase=agent_phase, requested_by=requested_by, priority=priority, ) From 1de1fd75044d64a72030b24b3023682648d5e4a7 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:37:48 +0800 Subject: [PATCH 06/15] feat(agent): configure agent settings, DI wiring, and dependencies - Add agent_llm_base_url, remove agent_llm_provider from settings and env - Wire settings into PlanService and ExecuteService via deps - Add anthropic SDK dependency (>=0.104.1) --- app/.env.example | 4 +- app/pyproject.toml | 1 + app/src/fileflash/core/deps.py | 6 +- app/src/fileflash/core/settings.py | 2 +- app/uv.lock | 120 +++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/app/.env.example b/app/.env.example index f6e870a..91a3052 100644 --- a/app/.env.example +++ b/app/.env.example @@ -61,8 +61,10 @@ AGENT_USER_DAILY_LIMIT=50 AGENT_USER_CONCURRENT_LIMIT=2 AGENT_STAGING_TTL_SEC=86400 AGENT_SSE_ENABLED=false -AGENT_LLM_PROVIDER=anthropic AGENT_LLM_MODEL=claude-sonnet-4-6 +# Configure provider compatibility only via base URL + API key token. +# Example (DeepSeek Anthropic-compatible endpoint): https://api.deepseek.com/anthropic +# AGENT_LLM_BASE_URL= # AGENT_LLM_API_KEY= AGENT_MCP_ENDPOINTS=[] diff --git a/app/pyproject.toml b/app/pyproject.toml index 2fa6b3a..8462a97 100644 --- a/app/pyproject.toml +++ b/app/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "sqlalchemy>=2.0.48", "uvicorn>=0.42.0", "python-multipart>=0.0.24", + "anthropic>=0.104.1", ] [project.scripts] fileflash = "fileflash.scripts.run_with_workers:main" diff --git a/app/src/fileflash/core/deps.py b/app/src/fileflash/core/deps.py index 6b486ac..c4fd5be 100644 --- a/app/src/fileflash/core/deps.py +++ b/app/src/fileflash/core/deps.py @@ -251,6 +251,7 @@ def get_agent_work_session_repository(db: AsyncSession = Depends(get_db)) -> Age def get_agent_plan_service( db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings_dep), jobs: BackgroundJobService = Depends(get_agent_background_job_service), plans: AgentPlanRepository = Depends(get_agent_plan_repository), settings_repo: AgentSettingsRepository = Depends(get_agent_settings_repository), @@ -258,21 +259,24 @@ def get_agent_plan_service( ) -> PlanService: return PlanService( db=db, + settings=settings, jobs=jobs, plans=plans, - settings=settings_repo, + settings_repo=settings_repo, work_sessions=work_sessions, ) def get_agent_execute_service( db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings_dep), jobs: BackgroundJobService = Depends(get_agent_background_job_service), plans: AgentPlanRepository = Depends(get_agent_plan_repository), work_sessions: AgentWorkSessionRepository = Depends(get_agent_work_session_repository), ) -> ExecuteService: return ExecuteService( db=db, + settings=settings, jobs=jobs, plans=plans, work_sessions=work_sessions, diff --git a/app/src/fileflash/core/settings.py b/app/src/fileflash/core/settings.py index f93a38b..80316a2 100644 --- a/app/src/fileflash/core/settings.py +++ b/app/src/fileflash/core/settings.py @@ -142,8 +142,8 @@ class Settings(BaseSettings): agent_user_concurrent_limit: int = Field(default=2, alias="AGENT_USER_CONCURRENT_LIMIT") agent_staging_ttl_sec: int = Field(default=86400, alias="AGENT_STAGING_TTL_SEC") agent_sse_enabled: bool = Field(default=False, alias="AGENT_SSE_ENABLED") - agent_llm_provider: str = Field(default="anthropic", alias="AGENT_LLM_PROVIDER") agent_llm_model: str = Field(default="claude-sonnet-4-6", alias="AGENT_LLM_MODEL") + agent_llm_base_url: str | None = Field(default=None, alias="AGENT_LLM_BASE_URL") agent_llm_api_key: str | None = Field(default=None, alias="AGENT_LLM_API_KEY") agent_mcp_endpoints_raw: str = Field(default="[]", alias="AGENT_MCP_ENDPOINTS") diff --git a/app/uv.lock b/app/uv.lock index f1aa17f..4913fb8 100644 --- a/app/uv.lock +++ b/app/uv.lock @@ -34,6 +34,25 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.104.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/c7/7a655b948916f777354648ce979f68b94d5b8dbdb5f61fed1f37fad9378c/anthropic-0.104.1.tar.gz", hash = "sha256:17362b6c45f527afcc9b0fdf62011ffd359726ab2ebcb1978ea0cc41bd8d8d40", size = 850081, upload-time = "2026-05-22T15:36:57.432Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/12/d9ab42790494d7c428391a46cd28492395566a6a8ccb138d681978594455/anthropic-0.104.1-py3-none-any.whl", hash = "sha256:35c8cb456f5a4405aafe1f10f03f6fcc54fa51fa8ec01d655cc4b437d120e9b7", size = 832996, upload-time = "2026-05-22T15:36:59.519Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -463,6 +482,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -472,6 +500,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -527,6 +564,7 @@ name = "fileflash" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "anthropic" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "fastapi-mail" }, @@ -554,6 +592,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=0.104.1" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "fastapi", specifier = ">=0.135.1" }, { name = "fastapi-mail", specifier = ">=1.6.2" }, @@ -745,6 +784,78 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1352,6 +1463,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" From 8445e5e8c131581f2aaccee520fa15fc7563cdc3 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:38:02 +0800 Subject: [PATCH 07/15] fix(agent): use BIGINT cast for user_id in repository queries - Replace unsafe :user_id IS NOT NULL AND owner_user_id = :user_id with owner_user_id = CAST(:user_id AS BIGINT) across all skill/mcp repository queries - Fixes NullPointer when user_id is passed as None and ensures consistent type handling --- app/src/fileflash/repositories/agent/mcp.py | 2 +- app/src/fileflash/repositories/agent/skill.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/fileflash/repositories/agent/mcp.py b/app/src/fileflash/repositories/agent/mcp.py index cb3dc86..fba358a 100644 --- a/app/src/fileflash/repositories/agent/mcp.py +++ b/app/src/fileflash/repositories/agent/mcp.py @@ -35,7 +35,7 @@ async def list_visible(self, *, user_id: int | None, enabled_only: bool = False) FROM v_agent_mcp_catalog WHERE ( visibility = 'system' - OR (:user_id IS NOT NULL AND owner_user_id = :user_id) + OR owner_user_id = CAST(:user_id AS BIGINT) ) AND (:enabled_only = FALSE OR enabled = TRUE) ORDER BY CASE WHEN visibility = 'system' THEN 0 ELSE 1 END, created_at DESC diff --git a/app/src/fileflash/repositories/agent/skill.py b/app/src/fileflash/repositories/agent/skill.py index 11bc593..e7ec0f3 100644 --- a/app/src/fileflash/repositories/agent/skill.py +++ b/app/src/fileflash/repositories/agent/skill.py @@ -34,7 +34,7 @@ async def list_visible(self, *, user_id: int | None, limit: int = 50) -> list[Ag search_text FROM v_agent_skill_catalog WHERE visibility = 'global' - OR (:user_id IS NOT NULL AND owner_user_id = :user_id) + OR owner_user_id = CAST(:user_id AS BIGINT) ORDER BY CASE WHEN visibility = 'global' THEN 0 ELSE 1 END, created_at DESC LIMIT :limit """ @@ -61,7 +61,7 @@ async def search_visible(self, *, user_id: int | None, query_text: str, limit: i updated_at, search_text FROM v_agent_skill_catalog - WHERE (visibility = 'global' OR (:user_id IS NOT NULL AND owner_user_id = :user_id)) + WHERE (visibility = 'global' OR owner_user_id = CAST(:user_id AS BIGINT)) AND ( :query_text = '' OR search_text ILIKE '%' || :query_text || '%' @@ -117,8 +117,8 @@ async def list_catalog_paginated( where_clause = """ ( (:visibility = 'global' AND visibility = 'global') - OR (:visibility = 'private' AND :user_id IS NOT NULL AND visibility = 'private' AND owner_user_id = :user_id) - OR (:visibility = 'all' AND (visibility = 'global' OR (:user_id IS NOT NULL AND owner_user_id = :user_id))) + OR (:visibility = 'private' AND visibility = 'private' AND owner_user_id = CAST(:user_id AS BIGINT)) + OR (:visibility = 'all' AND (visibility = 'global' OR owner_user_id = CAST(:user_id AS BIGINT))) ) AND ( :query_text = '' From 7089bcc28120940073001f6ce8eead32a4df1926 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:38:14 +0800 Subject: [PATCH 08/15] feat(agent): add agent worker process management to run_with_workers - Add --no-agent-worker and --agent-worker-count CLI flags - Spawn agent worker subprocesses when AGENT_ENABLED=true --- app/src/fileflash/scripts/run_with_workers.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/fileflash/scripts/run_with_workers.py b/app/src/fileflash/scripts/run_with_workers.py index 7f2db6d..a6b19a7 100644 --- a/app/src/fileflash/scripts/run_with_workers.py +++ b/app/src/fileflash/scripts/run_with_workers.py @@ -48,6 +48,17 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="Start API only (without file workers).", ) + parser.add_argument( + "--no-agent-worker", + action="store_true", + help="Do not start agent workers even when AGENT_ENABLED=true.", + ) + parser.add_argument( + "--agent-worker-count", + type=int, + default=max(1, int(getattr(settings, "agent_worker_concurrency", 1))), + help="Number of agent worker consumer processes when AGENT_ENABLED=true.", + ) return parser @@ -164,6 +175,20 @@ def main() -> int: processes.append(worker_proc) print(f"[run-with-workers] started {worker_name}: {_format_cmd(worker_cmd)}") + settings = get_settings() + should_start_agent_workers = ( + not args.no_worker + and getattr(settings, "agent_enabled", False) + and not args.no_agent_worker + ) + if should_start_agent_workers: + for index in range(max(1, int(args.agent_worker_count))): + worker_name = f"agent-worker-{index + 1}" + worker_cmd = [python, "-m", "fileflash.agents.worker"] + worker_proc = _spawn_process(worker_name, worker_cmd, cwd) + processes.append(worker_proc) + print(f"[run-with-workers] started {worker_name}: {_format_cmd(worker_cmd)}") + while True: for managed in processes: exit_code = managed.process.poll() From 5fd69c3de8a84ed80f6f53b3277ad749658db476 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:38:26 +0800 Subject: [PATCH 09/15] feat(agent): add builtin skill migration and backend tests - V13 migration seeds builtin:organizeByType skill with tool whitelist and plan template - test_agent_repositories: add acceptance tests for null user_id with BIGINT casts - test_agent_plan_execute_runtime: add runtime integration tests - test_agent_routes: add route-level plan/execute/cancel tests --- app/tests/test_agent_plan_execute_runtime.py | 369 ++++++++++++++++++ app/tests/test_agent_repositories.py | 103 +++++ app/tests/test_agent_routes.py | 125 ++++++ .../migrations/V13__agent_builtin_skills.sql | 47 +++ 4 files changed, 644 insertions(+) create mode 100644 app/tests/test_agent_plan_execute_runtime.py create mode 100644 app/tests/test_agent_routes.py create mode 100644 docker/flyway/migrations/V13__agent_builtin_skills.sql diff --git a/app/tests/test_agent_plan_execute_runtime.py b/app/tests/test_agent_plan_execute_runtime.py new file mode 100644 index 0000000..941f8a5 --- /dev/null +++ b/app/tests/test_agent_plan_execute_runtime.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from fileflash.agents.harness.policy import PolicyGuard, classify_tool_risk +from fileflash.agents.harness.router import ToolCall, ToolRouter +from fileflash.agents.runtime.llm import AnthropicPlannerClient +from fileflash.agents.runtime.plan_runner import PlanRunner +from fileflash.core.errors import ApiError +from fileflash.models import BackgroundJob +from fileflash.repositories import ( + AgentPlanRepository, + AgentSettingsRepository, + AgentWorkSessionRepository, +) +from fileflash.schemas.agent import ExecuteAgentRequest, PlanAgentRequest +from fileflash.services.agent.execute_service import ExecuteService +from fileflash.services.agent.plan_service import PlanService + + +class DummyDb: + def __init__(self) -> None: + self.scalar = AsyncMock() + self.execute = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + self.add = AsyncMock() + self.flush = AsyncMock() + self.commit = AsyncMock() + self.refresh = AsyncMock() + + +class FakeJobs: + def __init__(self) -> None: + self.kwargs = {} + + async def enqueue(self, db, **kwargs): # noqa: ANN001 + self.kwargs = kwargs + return BackgroundJob( + job_id=123, + task_type=kwargs["task_type"], + status="pending", + payload=kwargs["payload"], + result={}, + requested_by=kwargs["requested_by"], + max_attempts=kwargs["max_attempts"], + priority=kwargs["priority"], + scheduled_at=datetime.now(UTC), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + +def settings(**overrides): + base = { + "agent_enabled": True, + "agent_job_max_tokens": 50_000, + "agent_job_max_tool_calls": 100, + "agent_user_concurrent_limit": 2, + "agent_user_daily_limit": 50, + "agent_llm_base_url": None, + } + base.update(overrides) + return SimpleNamespace(**base) + + +@pytest.mark.asyncio +async def test_plan_enqueue_returns_frontend_shape_and_sets_phase(): + db = DummyDb() + db.scalar = AsyncMock(side_effect=[0, 0]) + jobs = FakeJobs() + service = PlanService( + db=db, + settings=settings(), + jobs=jobs, # type: ignore[arg-type] + plans=AgentPlanRepository(db), # type: ignore[arg-type] + settings_repo=AgentSettingsRepository(db), # type: ignore[arg-type] + work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + ) + payload = PlanAgentRequest.model_validate( + { + "input": "整理当前文件夹", + "context": { + "rootFolderId": "root", + "selectedFileIds": [], + "selectedFolderIds": [], + "currentPath": "/My Files", + }, + "executionPolicy": "confirm", + "dataPolicy": { + "allowFileContent": False, + "maxReadBytes": 1024, + "allowedMimeTypes": ["*/*"], + }, + "hints": { + "preferSkillId": None, + "maxSteps": 12, + "budgetTokens": 8000, + "reasoningEffort": "adaptive", + }, + } + ) + + result = await service.enqueue_plan(user_id=7, payload=payload) + + assert result.job_id == "123" + assert result.task_type == "agent.plan" + assert jobs.kwargs["agent_phase"] == "planning" + assert jobs.kwargs["payload"]["executionPolicy"] == "confirm" + assert jobs.kwargs["payload"]["hints"]["reasoningEffort"] == "adaptive" + + +@pytest.mark.asyncio +async def test_anthropic_planner_client_uses_sdk_and_parses_text_blocks(): + class FakeMessages: + def __init__(self) -> None: + self.kwargs = {} + + async def create(self, **kwargs): # noqa: ANN003 + self.kwargs = kwargs + return SimpleNamespace( + content=[ + SimpleNamespace( + type="text", + text='{"summary":"ok","proposedActions":[]}', + ) + ], + usage=SimpleNamespace(input_tokens=3, output_tokens=4), + ) + + fake_messages = FakeMessages() + fake_client = SimpleNamespace(messages=fake_messages) + client = AnthropicPlannerClient( + settings=settings( + agent_llm_api_key="test-key", + agent_llm_model="claude-test", + ), + client=fake_client, # type: ignore[arg-type] + ) + + result = await client.create_plan( + system_prompt="system", + user_prompt="user", + max_tokens=9000, + reasoning_effort="adaptive", + ) + + assert fake_messages.kwargs["model"] == "claude-test" + assert fake_messages.kwargs["max_tokens"] == 4096 + assert fake_messages.kwargs["system"] == "system" + assert fake_messages.kwargs["messages"] == [{"role": "user", "content": "user"}] + assert fake_messages.kwargs["thinking"] == {"type": "adaptive"} + assert "output_config" not in fake_messages.kwargs + assert result["summary"] == "ok" + assert result["_usage"] == {"input_tokens": 3, "output_tokens": 4} + + +@pytest.mark.asyncio +async def test_anthropic_planner_client_maps_reasoning_effort_to_output_config(): + class FakeMessages: + def __init__(self) -> None: + self.kwargs = {} + + async def create(self, **kwargs): # noqa: ANN003 + self.kwargs = kwargs + return SimpleNamespace( + content=[SimpleNamespace(type="text", text='{"proposedActions":[]}')], + usage={}, + ) + + fake_messages = FakeMessages() + client = AnthropicPlannerClient( + settings=settings( + agent_llm_api_key="test-key", + agent_llm_model="claude-test", + ), + client=SimpleNamespace(messages=fake_messages), # type: ignore[arg-type] + ) + + await client.create_plan( + system_prompt="system", + user_prompt="user", + max_tokens=800, + reasoning_effort="xhigh", + ) + + assert fake_messages.kwargs["max_tokens"] == 800 + assert fake_messages.kwargs["thinking"] == {"type": "enabled"} + assert fake_messages.kwargs["output_config"] == {"effort": "xhigh"} + + +def test_anthropic_planner_client_uses_configured_base_url(monkeypatch: pytest.MonkeyPatch): + captured: dict[str, object] = {} + + def fake_async_anthropic(**kwargs): # noqa: ANN003 + captured.update(kwargs) + return SimpleNamespace(messages=SimpleNamespace()) + + monkeypatch.setattr("fileflash.agents.runtime.llm.AsyncAnthropic", fake_async_anthropic) + client = AnthropicPlannerClient( + settings=settings( + agent_llm_api_key="test-key", + agent_llm_model="claude-test", + agent_llm_base_url="https://api.deepseek.com/anthropic", + ), + ) + + assert client._get_client("test-key").messages is not None + assert captured["api_key"] == "test-key" + assert captured["base_url"] == "https://api.deepseek.com/anthropic" + + +@pytest.mark.asyncio +async def test_execute_rejects_high_risk_plan_without_confirmation(): + db = DummyDb() + db.scalar.return_value = BackgroundJob( + job_id=99, + task_type="agent.plan", + status="succeeded", + payload={}, + result={}, + requested_by=7, + scheduled_at=datetime.now(UTC), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + plans = AgentPlanRepository(db) # type: ignore[arg-type] + plans.get_for_execute_binding = AsyncMock( + return_value=SimpleNamespace( + proposed_actions_json=[ + { + "step": 1, + "tool": "drive.deleteFile", + "input": {"fileId": "1"}, + "sideEffect": "write", + "riskLevel": "high", + "requiresConfirmation": True, + } + ] + ) + ) + service = ExecuteService( + db=db, + settings=settings(), + jobs=FakeJobs(), # type: ignore[arg-type] + plans=plans, + work_sessions=AgentWorkSessionRepository(db), # type: ignore[arg-type] + ) + payload = ExecuteAgentRequest.model_validate( + { + "planJobId": "99", + "planHash": "sha256:test", + "approval": {"confirmedBy": "7", "confirmedAt": datetime.now(UTC).isoformat()}, + } + ) + + with pytest.raises(ApiError) as exc: + await service.enqueue_execute(user_id=7, payload=payload) + + assert exc.value.status_code == 409 + assert exc.value.data["highRiskActions"][0]["tool"] == "drive.deleteFile" + + +@pytest.mark.asyncio +async def test_plan_runner_generates_stable_hash(monkeypatch: pytest.MonkeyPatch): + from fileflash.agents.runtime import plan_runner as plan_module + + monkeypatch.setattr(plan_module, "_choose_skill", AsyncMock(return_value=None)) + monkeypatch.setattr( + plan_module, + "_collect_context_metadata", + AsyncMock(return_value={"scope": "currentFolder", "files": [], "folders": []}), + ) + monkeypatch.setattr(plan_module, "_upsert_agent_plan", AsyncMock(return_value=None)) + planner = AsyncMock( + return_value={ + "summary": "Move files into folders.", + "proposedActions": [ + { + "step": 2, + "tool": "drive.moveFile", + "input": {"fileId": "1", "targetFolderId": "2"}, + }, + { + "step": 1, + "tool": "drive.createFolder", + "input": {"parentFolderId": "root", "name": "Docs"}, + } + ], + } + ) + client = SimpleNamespace(create_plan=planner) + request = PlanAgentRequest.model_validate( + { + "input": "organize", + "context": { + "rootFolderId": "root", + "selectedFileIds": [], + "selectedFolderIds": [], + "currentPath": "/My Files", + }, + "executionPolicy": "autopilot", + "dataPolicy": { + "allowFileContent": False, + "maxReadBytes": 1024, + "allowedMimeTypes": ["*/*"], + }, + "hints": { + "preferSkillId": None, + "maxSteps": 12, + "budgetTokens": 8000, + "reasoningEffort": "high", + }, + } + ) + job = BackgroundJob( + job_id=321, + 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), + ) + runner = PlanRunner(settings=settings(), planner_client=client) # type: ignore[arg-type] + + first = await runner.run(db=DummyDb(), job=job) # type: ignore[arg-type] + second = await runner.run(db=DummyDb(), job=job) # type: ignore[arg-type] + + assert first.plan_hash == second.plan_hash + assert first.requires_confirmation is False + assert [action.step for action in first.proposed_actions] == [1, 2] + assert "reasoningEffort" in planner.await_args.kwargs["user_prompt"] + + +@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] + router.file_service.move_file = AsyncMock( + return_value=SimpleNamespace( + model_dump=lambda by_alias: {"fileId": "1", "targetFolderId": "2"} + ) + ) + + result = await router.dispatch( + ToolCall( + tool_name="drive.moveFile", + arguments={"fileId": "1", "targetFolderId": "2"}, + ) + ) + + assert result == {"fileId": "1", "targetFolderId": "2"} + router.file_service.move_file.assert_awaited_once() diff --git a/app/tests/test_agent_repositories.py b/app/tests/test_agent_repositories.py index 129d9c6..9909ab2 100644 --- a/app/tests/test_agent_repositories.py +++ b/app/tests/test_agent_repositories.py @@ -28,6 +28,14 @@ def mappings(self): return list(self._rows) +class FakeScalarResult: + def __init__(self, value): + self._value = value + + def scalar(self): + return self._value + + class DummySession: def __init__(self) -> None: self.add = Mock() @@ -134,6 +142,86 @@ async def test_agent_skill_list_visible_maps_catalog_rows(): repo = AgentSkillRepository(session) items = await repo.list_visible(user_id=7) assert [item.skill_key for item in items] == ["builtin:organizeByType", "user:cleanup"] + statement = session.execute.await_args.args[0] + assert "owner_user_id = CAST(:user_id AS BIGINT)" in str(statement) + + +@pytest.mark.asyncio +async def test_agent_skill_list_visible_accepts_null_user_id(): + session = DummySession() + session.execute.return_value = FakeMappingResult([]) + + repo = AgentSkillRepository(session) + items = await repo.list_visible(user_id=None) + + assert items == [] + params = session.execute.await_args.args[1] + assert params["user_id"] is None + + +@pytest.mark.asyncio +async def test_agent_skill_search_visible_uses_bigint_cast_with_empty_query(): + session = DummySession() + session.execute.return_value = FakeMappingResult([]) + + repo = AgentSkillRepository(session) + items = await repo.search_visible(user_id=1, query_text="", limit=20) + + assert items == [] + statement = session.execute.await_args.args[0] + assert "owner_user_id = CAST(:user_id AS BIGINT)" in str(statement) + params = session.execute.await_args.args[1] + assert params["query_text"] == "" + + +@pytest.mark.asyncio +async def test_agent_skill_list_catalog_paginated_uses_bigint_cast_with_null_user(): + session = DummySession() + session.execute = AsyncMock( + side_effect=[ + FakeScalarResult(1), + FakeMappingResult( + [ + { + "skill_id": 1, + "skill_key": "builtin:organizeByType", + "name": "organizeByType", + "description": "Organize files by type", + "triggers_text": "organize, classify", + "tool_whitelist_json": ["drive.listFolder"], + "plan_template_json": {}, + "inputs_schema_json": {}, + "outputs_schema_json": {}, + "visibility": "global", + "owner_user_id": None, + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), + "search_text": "organize files by type", + } + ] + ), + ] + ) + + repo = AgentSkillRepository(session) + items, total_items = await repo.list_catalog_paginated( + user_id=None, + visibility="all", + query_text="", + page=1, + per_page=20, + ) + + assert total_items == 1 + assert [item.skill_key for item in items] == ["builtin:organizeByType"] + count_statement = session.execute.await_args_list[0].args[0] + list_statement = session.execute.await_args_list[1].args[0] + assert "owner_user_id = CAST(:user_id AS BIGINT)" in str(count_statement) + assert "owner_user_id = CAST(:user_id AS BIGINT)" in str(list_statement) + params = session.execute.await_args_list[1].args[1] + assert params["query_text"] == "" + assert params["offset"] == 0 + assert params["limit"] == 20 @pytest.mark.asyncio @@ -179,6 +267,21 @@ async def test_agent_mcp_list_visible_maps_catalog_rows(): repo = AgentMcpRepository(session) items = await repo.list_visible(user_id=7) assert [item.name for item in items] == ["web-search", "private-python"] + statement = session.execute.await_args.args[0] + assert "owner_user_id = CAST(:user_id AS BIGINT)" in str(statement) + + +@pytest.mark.asyncio +async def test_agent_mcp_list_visible_accepts_null_user_id(): + session = DummySession() + session.execute.return_value = FakeMappingResult([]) + + repo = AgentMcpRepository(session) + items = await repo.list_visible(user_id=None) + + assert items == [] + params = session.execute.await_args.args[1] + assert params["user_id"] is None @pytest.mark.asyncio diff --git a/app/tests/test_agent_routes.py b/app/tests/test_agent_routes.py new file mode 100644 index 0000000..f2a0506 --- /dev/null +++ b/app/tests/test_agent_routes.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_agent_execute_service, get_agent_plan_service, get_current_user +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.db.deps import get_db +from fileflash.models import BackgroundJob +from fileflash.models.tables_identity import User +from fileflash.routers.agent import router +from fileflash.schemas.agent import ExecuteAgentResponse, PlanAgentResponse + + +class StubPlanService: + async def enqueue_plan(self, *, user_id, payload): # noqa: ANN001 + return PlanAgentResponse(job_id="10", status="pending", task_type="agent.plan") + + +class StubExecuteService: + async def enqueue_execute(self, *, user_id, payload): # noqa: ANN001 + return ExecuteAgentResponse(job_id="11", status="pending", task_type="agent.execute") + + +class StubDb: + def __init__(self) -> None: + now = datetime.now(UTC) + self.job = BackgroundJob( + job_id=12, + task_type="agent.execute", + status="pending", + payload={}, + result={}, + requested_by=7, + scheduled_at=now, + created_at=now, + updated_at=now, + ) + + async def scalar(self, _query): # noqa: ANN001 + return self.job + + async def commit(self) -> None: + return None + + async def refresh(self, _job: BackgroundJob) -> None: + return None + + +def _user() -> User: + return User(user_id=7, username="u7", email="u7@example.com", password_hash="x") + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/v1") + app.add_exception_handler(ApiError, api_error_handler) + app.dependency_overrides[get_current_user] = _user + app.dependency_overrides[get_agent_plan_service] = lambda: StubPlanService() + app.dependency_overrides[get_agent_execute_service] = lambda: StubExecuteService() + app.dependency_overrides[get_db] = lambda: StubDb() + return TestClient(app) + + +def test_plan_route_returns_response_shell(): + response = _client().post( + "/api/v1/agent/plan", + json={ + "input": "organize", + "context": { + "rootFolderId": "root", + "selectedFileIds": [], + "selectedFolderIds": [], + "currentPath": "/My Files", + }, + "executionPolicy": "confirm", + "dataPolicy": { + "allowFileContent": False, + "maxReadBytes": 1024, + "allowedMimeTypes": ["*/*"], + }, + "hints": {"preferSkillId": None, "maxSteps": 12, "budgetTokens": 8000}, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["data"]["jobId"] == "10" + assert body["data"]["taskType"] == "agent.plan" + + +def test_execute_route_returns_response_shell(): + response = _client().post( + "/api/v1/agent/execute", + json={ + "planJobId": "10", + "planHash": "sha256:test", + "approval": { + "confirmedBy": "7", + "confirmedAt": datetime.now(UTC).isoformat(), + "highRiskConfirmed": True, + "highRiskConfirmedAt": datetime.now(UTC).isoformat(), + }, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["data"]["jobId"] == "11" + assert body["data"]["taskType"] == "agent.execute" + + +def test_cancel_route_returns_response_shell(): + response = _client().post("/api/v1/agent/cancel/12") + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["data"]["jobId"] == "12" + assert body["data"]["status"] == "canceled" + assert body["data"]["canceledAt"] diff --git a/docker/flyway/migrations/V13__agent_builtin_skills.sql b/docker/flyway/migrations/V13__agent_builtin_skills.sql new file mode 100644 index 0000000..da8af9a --- /dev/null +++ b/docker/flyway/migrations/V13__agent_builtin_skills.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:organizeByType', + 'Organize By Type', + 'Analyze the current folder metadata and propose folder creation, moves, and safe renames to organize files by type.', + 'organize files, organize by type, classify files, 整理文件, 文件分类', + '[ + "drive.listFolder", + "drive.createFolder", + "drive.moveFile", + "drive.moveFolder", + "drive.renameFile", + "drive.renameFolder", + "drive.deleteFile", + "drive.deleteFolder" + ]'::jsonb, + '{ + "strategy": "Group direct children by broad file type unless the user asks for a different organization rule.", + "scope": "selected items when present; otherwise current folder direct children only", + "deletePolicy": "delete actions are high risk and require explicit user confirmation" + }'::jsonb, + '{"type":"object"}'::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; From fff008c2d3f83a300428a51b99c20d883a96ba4a Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Mon, 25 May 2026 17:38:36 +0800 Subject: [PATCH 10/15] feat(web): wire AgentReasoningEffort and high-risk confirmation across frontend - Add AgentReasoningEffort, AgentActionRiskLevel types - Add reasoning effort selector to TaskInputDock with i18n labels - Wire reasoningEffort state through useAgentSession into plan/execute payloads - Add high-risk confirmation dialog in runExecute flow - Update mock handlers with riskLevel/requiresConfirmation fields and 409 on unconfirmed high-risk - Update Library dev page with new props --- .../organisms/agent/TaskInputDock.vue | 19 ++++++- web/src/composables/useAgentSession.ts | 32 +++++++++--- web/src/i18n/messages.ts | 18 +++++++ web/src/mock/handlers/agent.ts | 52 ++++++++++++++++++- web/src/pages/__dev/Library.vue | 36 +++++++++++-- .../pages/agent/workspace/AgentWorkspace.vue | 4 +- web/src/types/agent.d.ts | 8 +++ 7 files changed, 156 insertions(+), 13 deletions(-) diff --git a/web/src/components/organisms/agent/TaskInputDock.vue b/web/src/components/organisms/agent/TaskInputDock.vue index e1b18df..fbfc2c7 100644 --- a/web/src/components/organisms/agent/TaskInputDock.vue +++ b/web/src/components/organisms/agent/TaskInputDock.vue @@ -3,17 +3,19 @@ import { computed } from 'vue'; import Button from '../../molecules/Button.vue'; import Select from '../../molecules/Select.vue'; import { useLocaleStore } from '../../../store/locale'; -import type { AgentExecutionPolicy } from '../../../types/agent'; +import type { AgentExecutionPolicy, AgentReasoningEffort } from '../../../types/agent'; defineProps<{ modelValue: string; policy: AgentExecutionPolicy; + reasoningEffort: AgentReasoningEffort; disabled?: boolean; }>(); const emit = defineEmits<{ 'update:modelValue': [value: string]; 'update:policy': [value: AgentExecutionPolicy]; + 'update:reasoningEffort': [value: AgentReasoningEffort]; submit: []; }>(); @@ -26,6 +28,15 @@ const POLICY_OPTIONS = computed(() => [ { value: 'autopilot', label: t('agent.v2.input.policy.autopilot') }, ]); +const REASONING_OPTIONS = computed(() => [ + { value: 'adaptive', label: t('agent.v2.input.reasoning.adaptive') }, + { value: 'low', label: t('agent.v2.input.reasoning.low') }, + { value: 'medium', label: t('agent.v2.input.reasoning.medium') }, + { value: 'high', label: t('agent.v2.input.reasoning.high') }, + { value: 'xhigh', label: t('agent.v2.input.reasoning.xhigh') }, + { value: 'max', label: t('agent.v2.input.reasoning.max') }, +]); + const onInput = (e: Event) => { emit('update:modelValue', (e.target as HTMLTextAreaElement).value); }; @@ -56,6 +67,12 @@ const onKey = (e: KeyboardEvent) => { :options="POLICY_OPTIONS" @update:model-value="(v) => $emit('update:policy', v as AgentExecutionPolicy)" /> +