Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 51 additions & 0 deletions app/attachment_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Resolve chat-turn attachments into MCP InputFile argument shapes."""

from __future__ import annotations

import base64
from typing import Any

from app.turn_context import find_turn_attachment


def file_from_turn_attachment(value: Any) -> Any:
"""Map a chat attachment id/name to MCP inline file content when possible.

Hosted MCP rejects bare paths/ids; chat attachments already arrive on the
turn as bytes. Resolve them here so the model can pass attachment ids
straight into storage_create_file (and similar InputFile params).
"""
if isinstance(value, dict):
return value
if not isinstance(value, str):
return value

raw = value.strip()
if not raw:
return value
Comment thread
eldadfux marked this conversation as resolved.

att = find_turn_attachment(raw)
if not att:
return value

data = att.get("_bytes")
if not isinstance(data, (bytes, bytearray)):
return value

out: dict[str, Any] = {
"filename": str(att.get("name") or raw),
"content": base64.b64encode(bytes(data)).decode("ascii"),
"encoding": "base64",
}
mime = att.get("mime")
if isinstance(mime, str) and mime.strip():
out["mime_type"] = mime.strip()
return out


def resolve_file_arguments(arguments: Any) -> Any:
if not isinstance(arguments, dict) or "file" not in arguments:
return arguments
out = dict(arguments)
out["file"] = file_from_turn_attachment(out["file"])
return out
36 changes: 23 additions & 13 deletions app/graph/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,19 +119,22 @@ class Route(BaseModel):
Use current_time first for relative dates ("this week"). Prefer '$createdAt'
/ '$updatedAt' (with the dollar sign).
7) Mutating tools are not idempotent. Do not retry the same create with the same
concrete id. If the user did not give an id and did not ask for auto-ids,
call clarify (choice/text) instead of inventing slug ids; only use
user_id/bucket_id/database_id=\"unique()\" when the user accepts auto-generate
or clearly wants a quick create. When the user asks for N resources, call
create N times (prefer sequential tool rounds over one parallel burst).
Count only tool results that returned resource JSON - if a result says
\"Blocked duplicate\" or \"did NOT run\", that create failed; never claim it
succeeded. If create returns the resource JSON or says it is ready after
already_exists recovery, report that one success - do not search/list just to
restate that it exists. If a write returns an unclear/empty error, list/get
once before any further create. Before destructive deletes (or when multiple
targets match), call clarify with kind=confirm (danger=true) or kind=choice;
do not mutate further in the same turn after clarify.
concrete id. Default to id=\"unique()\" for file_id (storage uploads), and for
other resource ids on quick creates when the user did not supply a custom id
and the request is clearly \"just do it\" (e.g. upload this file, create a
bucket named X). Do NOT clarify with an auto-vs-custom choice — that choice
cannot collect a custom value. If the user explicitly wants to pick an ID,
use a single kind=text prompt with defaultValue/placeholder \"unique()\".
Never invent slug ids. When the user asks for N resources, call create N
times (prefer sequential tool rounds over one parallel burst). Count only
tool results that returned resource JSON - if a result says \"Blocked
duplicate\" or \"did NOT run\", that create failed; never claim it succeeded.
If create returns the resource JSON or says it is ready after already_exists
recovery, report that one success - do not search/list just to restate that
it exists. If a write returns an unclear/empty error, list/get once before
any further create. Before destructive deletes (or when multiple targets
match), call clarify with kind=confirm (danger=true) or kind=choice; do not
mutate further in the same turn after clarify.
8) Keep tool use lean - context is limited. Do not dump multiple skills.
9) Never claim you lack Appwrite knowledge when skills or MCP tools are available.
10) Use sandbox_exec only as a stub note for project sandbox work.
Expand Down Expand Up @@ -160,6 +163,13 @@ class Route(BaseModel):
key. Do not store secrets, passwords, or one-off task context.
15) Missing details: call clarify (choice / confirm / text) instead of guessing
names, IDs, permissions, or regions. Keep the spoken answer short and wait.
16) Chat attachments: when uploading to Storage (storage_create_file), pass the
attachment id (or exact filename) as the `file` argument and file_id=\"unique()\"
in the SAME turn when possible — do not clarify only to pick unique(). Cloud
re-supplies attachments on clarify follow-ups, but skipping the interrupt is
still better UX. The engine resolves the attachment binary. Do NOT ask for
public HTTPS URLs or local paths. Use read_attachment only to inspect content.
If no attachment is available, ask the user to attach the file (not for a URL).

Return a concise, practical answer the supervisor can finish with.
"""
Expand Down
19 changes: 14 additions & 5 deletions app/graph/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,12 @@ def read_attachment(
"Max characters to return for text-like files (default 12000)",
] = 12_000,
) -> str:
"""Read an attachment from the current request (stateless; this turn only)."""
"""Read an attachment from the current request (stateless; this turn only).

For Storage uploads, pass the attachment id/filename as storage_create_file's
`file` argument instead — the engine resolves it. Use this tool to inspect
text/content, not as a required step before uploading.
"""
att = find_turn_attachment(attachment_id)
if not att:
return (
Expand Down Expand Up @@ -339,10 +344,14 @@ def clarify(
) -> str:
"""Ask the user a structured follow-up in the Console (choices, confirm, text).

Use when required details are missing or before destructive deletes — do not
guess IDs, permissions, or unique(). After a successful call, stop mutating
this turn; wait for the user's next message with answers. The tool result is
the canonical JSON envelope the Console parses from tool_end.
Use when a required detail is missing or before destructive deletes. Do not
guess permissions or invent slug IDs. Prefer unique() without clarifying for
routine creates/uploads (especially file_id when attachments are present).
Never use a choice of \"auto vs custom ID\" — that cannot collect a custom
value; use kind=text with defaultValue unique() if the user must pick an ID.
After a successful call, stop mutating this turn; wait for the user's next
message with answers. The tool result is the canonical JSON envelope the
Console parses from tool_end.
"""
try:
return emit_clarify_prompts(prompts, title=title or None)
Expand Down
13 changes: 8 additions & 5 deletions app/mcp/write_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

from langchain_core.tools import BaseTool, StructuredTool

from app.attachment_upload import resolve_file_arguments

logger = logging.getLogger(__name__)

_ALPHABET = string.ascii_lowercase + string.digits
Expand Down Expand Up @@ -311,12 +313,13 @@ def _normalize_query_item(item: Any) -> Any:
def _normalize_arguments(arguments: Any) -> Any:
if not isinstance(arguments, dict):
return arguments
queries = arguments.get("queries")
out = resolve_file_arguments(arguments)
queries = out.get("queries")
if not isinstance(queries, list):
return arguments
out = dict(arguments)
out["queries"] = [_normalize_query_item(item) for item in queries]
return out
return out
normalized = dict(out)
normalized["queries"] = [_normalize_query_item(item) for item in queries]
return normalized


def _tool_name_from_payload(payload: dict[str, Any]) -> str:
Expand Down