Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/published/handbook/engineering/ai/sandboxed-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ The agent inside the sandbox gets:
- Access to the **PostHog MCP server** for querying data
- **Code execution** capabilities within the sandbox

### PostHog AI screenshot context

PostHog AI sandbox conversations can include up to four PNG or JPEG screenshots with a nonblank message. Each image can be up to 4 MiB, and the images in one message can total up to 10 MiB.

The browser uploads each image directly to object storage through a 15-minute signed form. The API validates and normalizes the image before it promotes a separate copy into the task run's artifact manifest. Only opaque attachment IDs pass through Temporal and the agent command protocol, so image bytes and storage credentials never enter workflow payloads.

Staged uploads are scoped to one team, user, and conversation. The agent server resolves promoted IDs only from the current task run and accepts normalized PNG or JPEG context artifacts uploaded by a user.

## Creating a sandboxed agent

Use `Task.create_and_run()` to launch a sandboxed agent from your product code:
Expand Down
55 changes: 40 additions & 15 deletions ee/api/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import uuid
import asyncio
from collections.abc import AsyncGenerator, Iterable
from typing import cast
from typing import Any, cast

from django.conf import settings
from django.core.exceptions import ValidationError
Expand Down Expand Up @@ -228,19 +228,26 @@ def _validate_sandbox_task(task_id: uuid.UUID, team_id: int, user_id: int | None


class SandboxOpenSerializer(serializers.Serializer):
"""Request body for `POST /conversations/{id}/open/`. A string `content` processes a turn; a
null/absent `content` warms a sandbox that idles awaiting the first message."""
"""Request body for `POST /conversations/{id}/open/`. Nonblank `content` processes a turn and may include
`attachment_ids`; null or absent `content` with no attachments warms a sandbox awaiting its first message."""

content = serializers.CharField(
required=False,
allow_null=True,
allow_blank=True,
max_length=40000,
help_text="The user's message text. Omit or null to warm a sandbox (boot + idle) ahead of the first message.",
help_text="The user's message text. Omit or null to warm a sandbox ahead of the first message, unless attachment_ids are provided.",
)
trace_id = serializers.UUIDField(
required=False, help_text="Client-generated trace id correlated with the resulting Run's SSE stream."
)
attachment_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
min_length=1,
max_length=4,
help_text="Finalized sandbox image attachment IDs to send with this message.",
)
# Deprecated with the legacy Max bridge (see SandboxAttachedContextItemSerializer) β€” do not extend.
attached_context = serializers.ListField(
required=False,
Expand All @@ -264,6 +271,15 @@ class SandboxOpenSerializer(serializers.Serializer):
),
)

def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
attachment_ids = attrs.get("attachment_ids") or []
if len(set(attachment_ids)) != len(attachment_ids):
raise serializers.ValidationError({"attachment_ids": "Attachment IDs must be unique."})
content = attrs.get("content") or ""
if attachment_ids and not content.strip():
raise serializers.ValidationError({"attachment_ids": "Attachment IDs require a message send."})
return attrs

def validate_task_id(self, value: uuid.UUID) -> uuid.UUID:
"""Resolve the Task to bind, scoped to the team and the requesting user's visibility.

Expand Down Expand Up @@ -729,20 +745,27 @@ def open(self, request: Request, *args, **kwargs):
if conversation.task_id is not None:
_validate_sandbox_task(conversation.task_id, self.team.id, request.user.id)

has_content = bool(serializer.validated_data.get("content"))
convert_to_acp, resumed_context = self._compute_sandbox_conversion(request, conversation, has_content)
content = serializer.validated_data.get("content") or ""
attachment_ids = serializer.validated_data.get("attachment_ids") or []
has_message = bool(content.strip()) or bool(attachment_ids)
convert_to_acp, resumed_context = self._compute_sandbox_conversion(request, conversation, has_message)

# Sandbox-only endpoint. A converting LangGraph thread is still LANGGRAPH here (the flip happens
# inside the routing service), so allow it through; reject any other non-sandbox conversation.
if conversation.agent_runtime != Conversation.AgentRuntime.SANDBOX and not convert_to_acp:
raise exceptions.ValidationError("This conversation is not on the sandbox runtime.")

if has_content and conversation.title is None:
conversation.title = serializer.validated_data["content"][:80]
if has_message and conversation.title is None:
conversation.title = content[:80] if content.strip() else "Image attachment"
conversation.save(update_fields=["title"])

return self._route_sandbox_message(
request, conversation, resumed_context=resumed_context, convert_to_acp=convert_to_acp, created=created
request,
conversation,
payload=serializer.validated_data,
resumed_context=resumed_context,
convert_to_acp=convert_to_acp,
created=created,
)

def _get_or_create_sandbox_conversation(
Expand Down Expand Up @@ -815,7 +838,7 @@ def _compute_sandbox_conversion(
resumed_context = None
return True, resumed_context

def _auto_route_repository(self, request: Request, conversation: Conversation, user: User) -> str | None:
def _auto_route_repository(self, payload: dict[str, Any], conversation: Conversation, user: User) -> str | None:
"""Auto-select the repository a sandbox conversation's first message is about.

Runs only on a first message β€” no backing Task yet (`task_id is None`) and real content.
Expand All @@ -825,7 +848,7 @@ def _auto_route_repository(self, request: Request, conversation: Conversation, u
"""
if conversation.task_id is not None:
return None
content = request.data.get("content")
content = payload.get("content")
if not isinstance(content, str) or not content.strip():
return None
return asgi_async_to_sync(tasks_facade.select_repository_for_message)(
Expand All @@ -837,23 +860,25 @@ def _route_sandbox_message(
request: Request,
conversation: Conversation,
*,
payload: dict[str, Any],
resumed_context: str | None = None,
convert_to_acp: bool = False,
created: bool = False,
) -> Response:
user = cast(User, request.user)
repository = self._auto_route_repository(request, conversation, user)
repository = self._auto_route_repository(payload, conversation, user)
result = SandboxSession(conversation, user).open(
request.data, resumed_context=resumed_context, convert_to_acp=convert_to_acp, repository=repository
payload, resumed_context=resumed_context, convert_to_acp=convert_to_acp, repository=repository
)
if result is None:
# Warm intent that provisioned nothing (pool full / released) β€” no run to open. Drop the
# row if we created it this request so a content-less warm can't leave orphaned conversations.
if created:
conversation.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
content = request.data.get("content")
if isinstance(content, str) and content.strip():
content = payload.get("content")
attachment_ids = payload.get("attachment_ids") or []
if (isinstance(content, str) and content.strip()) or attachment_ids:
report_user_action(
user,
"prompt sent",
Expand Down
1 change: 1 addition & 0 deletions ee/api/tests/test_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1767,6 +1767,7 @@ def test_open_validates_request_body(self):
{"content": "x" * 40001}, # over the content length cap
{"content": "hello", "trace_id": "not-a-uuid"}, # malformed trace id
{"content": "hello", "initial_permission_mode": "full-access"}, # Codex-only mode, not valid for Claude
{"content": " ", "attachment_ids": [str(uuid.uuid4())]},
]
for payload in bad_payloads:
with patch("ee.api.conversation.SandboxSession") as m_session:
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/scenes/max/components/HandsFreeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import { handsFreeLogic } from '../handsFreeLogic'

interface HandsFreeButtonProps {
panelId?: string
disabledReason?: string
}

export function HandsFreeButton({ panelId }: HandsFreeButtonProps): JSX.Element | null {
export function HandsFreeButton({ panelId, disabledReason }: HandsFreeButtonProps): JSX.Element | null {
const flagEnabled = useFeatureFlag('MAX_HANDS_FREE')
const { status, canUseHandsFree } = useValues(handsFreeLogic({ panelId }))
const { toggleHandsFree } = useActions(handsFreeLogic({ panelId }))
Expand All @@ -30,8 +31,9 @@ export function HandsFreeButton({ panelId }: HandsFreeButtonProps): JSX.Element
type="tertiary"
icon={<IconMicrophone />}
onClick={toggleHandsFree}
tooltip="Enter hands-free"
tooltip={disabledReason || 'Enter hands-free'}
aria-label="Enter hands-free"
disabledReason={disabledReason}
/>
</Shortcut>
)
Expand Down
128 changes: 128 additions & 0 deletions frontend/src/scenes/max/components/QuestionInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { BindLogic, Provider } from 'kea'

import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { projectLogic } from 'scenes/projectLogic'

import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'

import {
assistantAttachmentsDeleteCreate,
assistantAttachmentsFinalizeCreate,
assistantAttachmentsPrepareCreate,
} from 'products/posthog_ai/frontend/generated/api'

import { handsFreeLogic } from '../handsFreeLogic'
import { maxGlobalLogic } from '../maxGlobalLogic'
import { maxLogic } from '../maxLogic'
import { maxThreadLogic } from '../maxThreadLogic'
Expand All @@ -21,13 +32,53 @@
{ virtual: true }
)

jest.mock('products/posthog_ai/frontend/generated/api', () => ({
assistantAttachmentsPrepareCreate: jest.fn(),
assistantAttachmentsFinalizeCreate: jest.fn(),
assistantAttachmentsDeleteCreate: jest.fn(),
}))

describe('QuestionInput', () => {
let maxLogicInstance: ReturnType<typeof maxLogic.build>
let projectLogicInstance: ReturnType<typeof projectLogic.build>
let threadLogicInstance: ReturnType<typeof maxThreadLogic.build>

beforeEach(() => {
useMocks(maxMocks)
initKeaTests()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: jest.fn((file: File) => `blob:${file.name}`),
})
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() })
const baseFetch = global.fetch.bind(global)
global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => {
if (typeof input === 'string' && input.startsWith('https://upload.test')) {
return Promise.resolve({ ok: true } as Response)
}
return baseFetch(input, init)
}) as any
;(assistantAttachmentsPrepareCreate as jest.Mock).mockResolvedValue({
attachments: [
{
id: 'attachment-1',
file_name: 'upload.png',
content_type: 'image/png',
size: 1,
upload_url: 'https://upload.test',
upload_fields: { key: 'value' },
},
],
})
;(assistantAttachmentsFinalizeCreate as jest.Mock).mockResolvedValue({
id: 'attachment-1',
file_name: 'upload.png',
content_type: 'image/png',
size: 1,
width: 1,
height: 1,
})
;(assistantAttachmentsDeleteCreate as jest.Mock).mockResolvedValue(undefined)

const maxGlobalLogicInstance = maxGlobalLogic()
maxGlobalLogicInstance.mount()
Expand All @@ -36,6 +87,10 @@
maxLogicInstance = maxLogic({ panelId: 'test' })
maxLogicInstance.mount()

projectLogicInstance = projectLogic()
projectLogicInstance.mount()
projectLogicInstance.actions.loadCurrentProjectSuccess({ id: 1, name: 'Test project' } as any)

const threadProps = { panelId: 'test', conversationId: maxLogicInstance.values.frontendConversationId }
threadLogicInstance = maxThreadLogic(threadProps)
threadLogicInstance.mount()
Expand All @@ -56,6 +111,7 @@
threadLogicInstance?.unmount()
maxLogicInstance?.cache.eventSourceController?.abort()
maxLogicInstance?.unmount()
projectLogicInstance?.unmount()
jest.restoreAllMocks()
})

Expand Down Expand Up @@ -109,6 +165,78 @@
await waitFor(() => expect(slashCommandItem()).toBeInTheDocument())
})

it('keeps send disabled for a blank message even after an attachment is ready', async () => {
threadLogicInstance.actions.setIsSandboxMode(true)
await waitFor(() => expect(screen.getByLabelText('Choose PNG or JPEG images')).toBeInTheDocument())
const fileInput = screen.getByLabelText('Choose PNG or JPEG images') as HTMLInputElement
const sendButton = document.querySelector('[data-attr="max-send-message"]') as HTMLElement

fireEvent.change(fileInput, {
target: { files: [new File(['a'], 'upload.png', { type: 'image/png' })] },
})

await waitFor(() => expect(screen.getByText('upload.png')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('Ready')).toBeInTheDocument())
expect(sendButton).toHaveAttribute('aria-disabled', 'true')
})

it('disables hands-free mode while an image is staged', async () => {
featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.MAX_HANDS_FREE], {
[FEATURE_FLAGS.MAX_HANDS_FREE]: true,
})
handsFreeLogic({ panelId: 'test' }).actions.setSdkAvailable(true)
threadLogicInstance.actions.setIsSandboxMode(true)
await waitFor(() => expect(screen.getByLabelText('Choose PNG or JPEG images')).toBeInTheDocument())

fireEvent.change(screen.getByLabelText('Choose PNG or JPEG images'), {
target: { files: [new File(['a'], 'upload.png', { type: 'image/png' })] },
})

await waitFor(() => expect(screen.getByText('Ready')).toBeInTheDocument())
expect(screen.getByLabelText('Enter hands-free')).toHaveAttribute('aria-disabled', 'true')
})

it.each([
[
'drop',
(input: HTMLElement, file: File) =>
fireEvent.drop(input.closest('label') as HTMLElement, {
dataTransfer: { files: [file], types: ['Files'] },
}),
],
[
'paste',
(input: HTMLElement, file: File) =>
fireEvent.paste(input, {
clipboardData: {
items: [
{
kind: 'file',
getAsFile: () => file,
},
],
},
}),
],
])('adds sandbox attachments via %s', async (method, addFile) => {
threadLogicInstance.actions.setIsSandboxMode(true)
await waitFor(() => expect(screen.getByLabelText('Choose PNG or JPEG images')).toBeInTheDocument())
const input = screen.getByRole('textbox')
const composer = input.closest('label') as HTMLElement
const file = new File(['a'], `from-${method}.png`, { type: 'image/png' })

if (method === 'drop') {
fireEvent.dragEnter(composer, { dataTransfer: { files: [file], types: ['Files'] } })
await waitFor(() => expect(composer.className).toContain('bg-accent-highlight-secondary/20'))
}

addFile(input, file)

await waitFor(() => expect(screen.getByText(`from-${method}.png`)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('Ready')).toBeInTheDocument())
await waitFor(() => expect(composer.className).not.toContain('bg-accent-highlight-secondary/20'))
})

describe('stop button cancel state', () => {
const sendButton = (): HTMLElement | null => document.querySelector('[data-attr="max-send-message"]')
const stopButton = (): HTMLElement | null => document.querySelector('[data-attr="max-stop-generation"]')
Expand Down
Loading
Loading