diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 65618c3102b4..0c9d99a5f4d2 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -83,10 +83,13 @@ def available(self) -> bool: # IMPORTANT: Must be less than frontend timeout (12s in useCopilotPage.ts) _HEARTBEAT_INTERVAL = 10.0 # seconds + # Appended to the system prompt to inform the agent about available tools. # The SDK built-in Bash is NOT available — use mcp__copilot__bash_exec instead, # which has kernel-level network isolation (unshare --net). -_SDK_TOOL_SUPPLEMENT = """ +def _build_sdk_tool_supplement(cwd: str) -> str: + """Build the SDK tool supplement with the actual working directory injected.""" + return f""" ## Tool notes @@ -94,9 +97,16 @@ def available(self) -> bool: - The SDK built-in Bash tool is NOT available. Use the `bash_exec` MCP tool for shell commands — it runs in a network-isolated sandbox. +### Working directory +- Your working directory is: `{cwd}` +- All SDK Read/Write/Edit/Glob/Grep tools AND `bash_exec` operate inside this + directory. This is the ONLY writable path — do not attempt to read or write + anywhere else on the filesystem. +- Use relative paths or absolute paths under `{cwd}` for all file operations. + ### Two storage systems — CRITICAL to understand -1. **Ephemeral working directory** (`/tmp/copilot-/`): +1. **Ephemeral working directory** (`{cwd}`): - Shared by SDK Read/Write/Edit/Glob/Grep tools AND `bash_exec` - Files here are **lost between turns** — do NOT rely on them persisting - Use for temporary work: running scripts, processing data, etc. @@ -122,6 +132,21 @@ def available(self) -> bool: 2. At the start of a new turn, call `list_workspace_files` to see what files are available from previous turns +### Sharing files with the user +After saving a file to the persistent workspace with `write_workspace_file`, +share it with the user by embedding the `download_url` from the response in +your message as a Markdown link or image: + +- **Any file** — shows as a clickable download link: + `[report.csv](workspace://file_id#text/csv)` +- **Image** — renders inline in chat: + `![chart](workspace://file_id#image/png)` +- **Video** — renders inline in chat with player controls: + `![recording](workspace://file_id#video/mp4)` + +The `download_url` field in the `write_workspace_file` response is already +in the correct format — paste it directly after the `(` in the Markdown. + ### Long-running tools Long-running tools (create_agent, edit_agent, etc.) are handled asynchronously. You will receive an immediate response; the actual result @@ -132,6 +157,7 @@ def available(self) -> bool: All tasks must run in the foreground. """ + STREAM_LOCK_PREFIX = "copilot:stream:lock:" @@ -460,14 +486,13 @@ async def stream_chat_completion_sdk( _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) - # Build system prompt (reuses non-SDK path with Langfuse support) - has_history = len(session.messages) > 1 - system_prompt, _ = await _build_system_prompt( - user_id, has_conversation_history=has_history - ) - system_prompt += _SDK_TOOL_SUPPLEMENT message_id = str(uuid.uuid4()) stream_id = str(uuid.uuid4()) + stream_completed = False + use_resume = False + resume_file: str | None = None + captured_transcript = CapturedTranscript() + sdk_cwd = "" # Acquire stream lock to prevent concurrent streams to the same session lock = AsyncClusterLock( @@ -490,21 +515,30 @@ async def stream_chat_completion_sdk( ) return - yield StreamStart(messageId=message_id, sessionId=session_id) - - stream_completed = False - # Initialise variables before the try so the finally block can - # always attempt transcript upload regardless of errors. - sdk_cwd = "" - use_resume = False - resume_file: str | None = None - captured_transcript = CapturedTranscript() - + # Make sure there is no more code between the lock acquitition and try-block. try: - # Use a session-specific temp dir to avoid cleanup race conditions - # between concurrent sessions. - sdk_cwd = _make_sdk_cwd(session_id) - os.makedirs(sdk_cwd, exist_ok=True) + # Build system prompt (reuses non-SDK path with Langfuse support). + # Pre-compute the cwd here so the exact working directory path can be + # injected into the supplement instead of the generic placeholder. + # Catch ValueError early so the failure yields a clean StreamError rather + # than propagating outside the stream error-handling path. + has_history = len(session.messages) > 1 + try: + sdk_cwd = _make_sdk_cwd(session_id) + os.makedirs(sdk_cwd, exist_ok=True) + except (ValueError, OSError) as e: + logger.error("[SDK] [%s] Invalid SDK cwd: %s", session_id[:12], e) + yield StreamError( + errorText="Unable to initialize working directory.", + code="sdk_cwd_error", + ) + return + system_prompt, _ = await _build_system_prompt( + user_id, has_conversation_history=has_history + ) + system_prompt += _build_sdk_tool_supplement(sdk_cwd) + + yield StreamStart(messageId=message_id, sessionId=session_id) set_execution_context(user_id, session) try: diff --git a/autogpt_platform/backend/backend/copilot/tools/workspace_files.py b/autogpt_platform/backend/backend/copilot/tools/workspace_files.py index fa709b7e0440..b1d54f665373 100644 --- a/autogpt_platform/backend/backend/copilot/tools/workspace_files.py +++ b/autogpt_platform/backend/backend/copilot/tools/workspace_files.py @@ -214,7 +214,11 @@ class WorkspaceWriteResponse(ToolResponseBase): file_id: str name: str path: str + mime_type: str size_bytes: int + # workspace:// URL the agent can embed directly in chat to give the user a link. + # Format: workspace://# (frontend resolves to download URL) + download_url: str source: str | None = None # "content", "base64", or "copied from " content_preview: str | None = None # First 200 chars for text files @@ -680,11 +684,21 @@ async def _execute( except Exception: pass + # Strip MIME parameters (e.g. "text/html; charset=utf-8" → "text/html") + # and normalise to lowercase so the fragment is URL-safe. + normalized_mime = (rec.mime_type or "").split(";", 1)[0].strip().lower() + download_url = ( + f"workspace://{rec.id}#{normalized_mime}" + if normalized_mime + else f"workspace://{rec.id}" + ) return WorkspaceWriteResponse( file_id=rec.id, name=rec.name, path=rec.path, + mime_type=normalized_mime, size_bytes=rec.size_bytes, + download_url=download_url, source=source, content_preview=preview, message=msg, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx index 0dc8e60c6941..218f7e540bd1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx @@ -104,11 +104,15 @@ function resolveWorkspaceUrls(text: string): string { // These are blocked by Streamdown's rehype-harden sanitizer because // "workspace://" is not in the allowed URL-scheme whitelist, which causes // "[blocked]" to appear next to the link text. + // Use an absolute URL so Streamdown's "Copy link" button copies the full + // URL (including host) rather than just the path. resolved = resolved.replace( /(? { const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId); - const url = `/api/proxy${apiPath}`; + const origin = + typeof window !== "undefined" ? window.location.origin : ""; + const url = `${origin}/api/proxy${apiPath}`; return `[${linkText || "Download file"}](${url})`; }, ); diff --git a/autogpt_platform/frontend/src/app/globals.css b/autogpt_platform/frontend/src/app/globals.css index 4a1691eec351..7aa6f65895cf 100644 --- a/autogpt_platform/frontend/src/app/globals.css +++ b/autogpt_platform/frontend/src/app/globals.css @@ -181,6 +181,11 @@ body[data-google-picker-open="true"] [data-dialog-content] { pointer-events: none !important; } +/* Streamdown external link dialog: "Open link" button */ +[data-streamdown="link-safety-modal"] button:last-of-type { + color: black; +} + /* CoPilot chat table styling — remove left/right borders, increase padding */ [data-streamdown="table-wrapper"] table { border-left: none;