Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8c0181b
feat(agent): add agent schemas, API routes, and layer init exports
AperturePlus May 25, 2026
2bb1171
feat(agent): implement plan_runner and execute_runner runtime
AperturePlus May 25, 2026
cdf6f42
feat(agent): implement policy guard and tool router harness
AperturePlus May 25, 2026
b93befc
feat(agent): add LLM planner client and agent worker process
AperturePlus May 25, 2026
b4c5916
feat(agent): add plan and execute services with background job support
AperturePlus May 25, 2026
1de1fd7
feat(agent): configure agent settings, DI wiring, and dependencies
AperturePlus May 25, 2026
8445e5e
fix(agent): use BIGINT cast for user_id in repository queries
AperturePlus May 25, 2026
7089bcc
feat(agent): add agent worker process management to run_with_workers
AperturePlus May 25, 2026
5fd69c3
feat(agent): add builtin skill migration and backend tests
AperturePlus May 25, 2026
fff008c
feat(web): wire AgentReasoningEffort and high-risk confirmation acros…
AperturePlus May 25, 2026
b3e3b45
test(web): add missing file-api mocks to test specs
AperturePlus May 25, 2026
1b729fb
feat(agent): add symbolic placeholder validation in plan and execute …
AperturePlus May 25, 2026
4b07b0f
fix(agent): normalize datetime and non-serializable types in tool rou…
AperturePlus May 25, 2026
6b7f1c6
test(agent): add tests for placeholder validation and datetime serial…
AperturePlus May 25, 2026
5014c91
fix(web): surface backend error messages from agent execute/plan/canc…
AperturePlus May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=[]

Expand Down
1 change: 1 addition & 0 deletions app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion app/src/fileflash/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
ToolCall,
ToolRouter,
)
from .runtime import ExecuteRunner, PlanRunner, SubagentRunner
from .runtime import AgentJobCanceled, ExecuteRunner, PlanRunner, SubagentRunner

__all__ = [
"AgentJobCanceled",
"AgentEvent",
"CheckpointStore",
"ContextBudget",
Expand Down
68 changes: 66 additions & 2 deletions app/src/fileflash/agents/harness/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,77 @@

from dataclasses import dataclass, field

from ...schemas.agent import AgentProposedAction


@dataclass(slots=True)
class PolicyDecision:
allowed: bool
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)
132 changes: 131 additions & 1 deletion app/src/fileflash/agents/harness/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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, mode="json")

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, mode="json")
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, mode="json")

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, mode="json")

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, mode="json")

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, mode="json")

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, mode="json")

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, mode="json")

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
4 changes: 2 additions & 2 deletions app/src/fileflash/agents/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading