fix(copilot): inject working directory into SDK prompt + workspace download links (#12215)

## Summary

- Replaces the static `_SDK_TOOL_SUPPLEMENT` placeholder path with
`_build_sdk_tool_supplement(cwd: str)` that injects the session-specific
working directory
- `sdk_cwd` is computed once via `_make_sdk_cwd(session_id)`,
`os.makedirs` is called after lock acquisition (inside the protected
`try/finally`), and the same variable is used everywhere — no drift
between prompt and execution directory
- Added `ValueError`/`OSError` error handling for cwd preparation with
proper `StreamError` emission
- Teaches the SDK agent how to share workspace files with the user via
`workspace://` Markdown links (images render inline, videos render with
player controls, other files as download links)
- `WorkspaceWriteResponse` now includes `download_url` (pre-formatted
`workspace://file_id#mime` string) and a normalised `mime_type` field
(MIME parameters stripped, lowercased)
- Frontend: workspace `workspace://` regular links now resolve to
absolute URLs so Streamdown's "Copy link" copies the full URL
- Frontend: Streamdown's "Open link" button colour overridden to match
the design system (violet accent) — previously near-invisible in dark
mode due to `--primary` resolving to near-white

## Motivation

The SDK agent was seeing a hardcoded placeholder path in the system
prompt instead of the real working directory, causing it to reference
wrong paths in tool calls. Additionally, there was no guidance for the
agent on how to share files it writes to the workspace with the user in
chat.

## Test plan

- [ ] CI green (test 3.11 / 3.12 / 3.13)
- [ ] Start a copilot session with `CHAT_USE_CLAUDE_AGENT_SDK=true` and
verify the agent references the correct `sdk_cwd` path in its tool calls
- [ ] Ask the agent to write a file and confirm it responds with a
clickable download link / inline image using the `workspace://` syntax
- [ ] Verify the "Open link" button in the Streamdown external-link
dialog is visible in both light and dark mode
- [ ] Click "Copy link" on a workspace file link and confirm it copies
the full URL (including host)
This commit is contained in:
Zamil Majdy
2026-02-27 00:26:19 +07:00
committed by GitHub
parent ed729ddbe2
commit b30418d833
4 changed files with 80 additions and 23 deletions

View File

@@ -83,10 +83,13 @@ COPILOT_SYSTEM_PREFIX = "[COPILOT_SYSTEM]" # Renders as system info message
# 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 @@ _SDK_TOOL_SUPPLEMENT = """
- 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-<session>/`):
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 @@ When you create or modify important files (code, configs, outputs), you MUST:
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 @@ is delivered to the user via a background stream.
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:

View File

@@ -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://<file_id>#<mime_type> (frontend resolves to download URL)
download_url: str
source: str | None = None # "content", "base64", or "copied from <path>"
content_preview: str | None = None # First 200 chars for text files
@@ -680,11 +684,21 @@ class WriteWorkspaceFileTool(BaseTool):
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,

View File

@@ -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(
/(?<!!)\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#[^)#\s]*)?\)/g,
(_match, linkText: string, fileId: string) => {
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})`;
},
);

View File

@@ -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;