Compare commits
9 Commits
389a8fb655
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c21e4e7f7b | |||
| 11b3bc2bbf | |||
| a21329bb04 | |||
| a84fd517a8 | |||
| c632a9d147 | |||
| c6d8d4dfe6 | |||
| d17b79ad95 | |||
| a897d1cd00 | |||
| 7dd333b19d |
@@ -0,0 +1,14 @@
|
|||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
**/*.egg-info
|
||||||
|
.venv
|
||||||
|
backend/.venv
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
artifacts
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
tests
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: Deploy Research Workbench
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Pull latest code
|
||||||
|
working-directory: /home/ken/workspace/research-workbench
|
||||||
|
run: |
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
- name: Stop old containers
|
||||||
|
working-directory: /home/ken/workspace/research-workbench
|
||||||
|
run: |
|
||||||
|
docker rm -f car-help 2>/dev/null || true
|
||||||
|
docker compose down 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Build and start container
|
||||||
|
working-directory: /home/ken/workspace/research-workbench
|
||||||
|
env:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
AUTH_USER: ${{ secrets.AUTH_USER }}
|
||||||
|
AUTH_PASS_HASH: ${{ secrets.AUTH_PASS_HASH }}
|
||||||
|
run: |
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
- name: Wait for healthy
|
||||||
|
run: |
|
||||||
|
echo "Waiting for container to start..."
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if curl -sf http://localhost:8080/ > /dev/null 2>&1; then
|
||||||
|
echo "App is up at http://localhost:8080/"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "ERROR: App did not respond within 180s"
|
||||||
|
docker compose -f /home/ken/workspace/research-workbench/docker-compose.yml logs --tail=50
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Verify new UI is served
|
||||||
|
run: |
|
||||||
|
BODY=$(curl -sf http://localhost:8080/ || echo "FETCH_FAILED")
|
||||||
|
if echo "$BODY" | grep -qi "car-help"; then
|
||||||
|
echo "ERROR: Still serving old car-help UI!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Deploy successful - new UI is live at https://research.ampbox.io"
|
||||||
+37
-16
@@ -1,12 +1,15 @@
|
|||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
# Install system dependencies
|
# ── System dependencies ────────────────────────────────────────────────────────
|
||||||
|
# Includes Xvfb/VNC stack, git (needed for pip's git+https installs),
|
||||||
|
# and Chromium runtime libs (for the playwright-managed Chromium binary).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
xvfb \
|
xvfb \
|
||||||
x11vnc \
|
x11vnc \
|
||||||
websockify \
|
websockify \
|
||||||
novnc \
|
novnc \
|
||||||
curl \
|
curl \
|
||||||
|
git \
|
||||||
libnss3 \
|
libnss3 \
|
||||||
libatk1.0-0 \
|
libatk1.0-0 \
|
||||||
libatk-bridge2.0-0 \
|
libatk-bridge2.0-0 \
|
||||||
@@ -25,40 +28,58 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Node.js 22.x from nodesource.com
|
# ── Node.js 22.x ──────────────────────────────────────────────────────────────
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install @anthropic-ai/playwright-cli globally via npm
|
# ── npm global tools: @playwright/cli + Chrome ────────────────────────────────
|
||||||
RUN npm install -g @anthropic-ai/playwright-cli
|
# @playwright/cli — token-efficient browser CLI used by research agents
|
||||||
|
# (playwright-cli -s=research open/snapshot/click/type …)
|
||||||
|
# playwright-cli defaults to Chrome at /opt/google/chrome/chrome. Install it directly.
|
||||||
|
RUN npm install -g @playwright/cli \
|
||||||
|
&& curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o /tmp/chrome.deb \
|
||||||
|
&& apt-get update && apt-get install -y /tmp/chrome.deb \
|
||||||
|
&& rm -f /tmp/chrome.deb && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install uv via pip
|
# ── Python tooling ────────────────────────────────────────────────────────────
|
||||||
RUN pip install uv
|
RUN pip install uv
|
||||||
|
# overlayfs inside Docker doesn't support reflinks → use copy mode to avoid EAGAIN
|
||||||
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
# Copy backend dependency files and install Python dependencies
|
# ── amplifierd: Amplifier session daemon ──────────────────────────────────────
|
||||||
|
# Not on PyPI — source is github.com/microsoft/amplifierd.
|
||||||
|
# uv pip --system installs to /usr/local/bin/amplifierd (on PATH by default).
|
||||||
|
#
|
||||||
|
# amplifier-foundation is also not on PyPI (it's a pure git package that
|
||||||
|
# amplifierd depends on), so we install it explicitly in the same step.
|
||||||
|
# amplifier-core IS on PyPI and will be resolved automatically.
|
||||||
|
RUN uv pip install --system \
|
||||||
|
"amplifierd @ git+https://github.com/microsoft/amplifierd" \
|
||||||
|
"amplifier-module-provider-anthropic @ git+https://github.com/microsoft/amplifier-module-provider-anthropic"
|
||||||
|
|
||||||
|
# ── Frontend: install deps and build ──────────────────────────────────────────
|
||||||
|
# Copy package manifests first so npm ci is cached independently of source changes.
|
||||||
|
COPY frontend/package.json frontend/package-lock.json /app/frontend/
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ /app/frontend/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Backend: install Python deps ──────────────────────────────────────────────
|
||||||
COPY backend/pyproject.toml backend/uv.lock /app/backend/
|
COPY backend/pyproject.toml backend/uv.lock /app/backend/
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
RUN uv sync --frozen
|
RUN uv sync --frozen
|
||||||
|
|
||||||
# Install Playwright chromium
|
# ── Application files ─────────────────────────────────────────────────────────
|
||||||
RUN uv run playwright install chromium
|
|
||||||
|
|
||||||
# Install amplifierd
|
|
||||||
RUN uv pip install amplifierd
|
|
||||||
|
|
||||||
# Copy application files
|
|
||||||
COPY backend/*.py /app/backend/
|
COPY backend/*.py /app/backend/
|
||||||
COPY bundle/ /app/bundle/
|
COPY bundle/ /app/bundle/
|
||||||
COPY frontend/dist/ /app/frontend/dist/
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Create artifacts directory
|
|
||||||
RUN mkdir -p /app/artifacts
|
RUN mkdir -p /app/artifacts
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 8080 6080
|
EXPOSE 8080 6080
|
||||||
|
|
||||||
ENV DISPLAY=:99
|
ENV DISPLAY=:99
|
||||||
|
|||||||
+80
-20
@@ -40,13 +40,19 @@ class AmplifierdEventTranslator:
|
|||||||
"""Translate a single amplifierd SSE event into zero or more AG-UI events.
|
"""Translate a single amplifierd SSE event into zero or more AG-UI events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event: Dict with 'event_type' and 'data' keys.
|
event: Dict with 'event_type' and 'data' keys. The 'data' value is
|
||||||
|
the raw SSE payload from amplifierd, which wraps the event-specific
|
||||||
|
fields under a nested ``"data"`` key
|
||||||
|
(e.g. ``{"event": "...", "data": {...}, "session_id": ..., ...}``).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of ag_ui.core event objects (may be empty for unknown events).
|
List of ag_ui.core event objects (may be empty for unknown events).
|
||||||
"""
|
"""
|
||||||
event_type = event.get("event_type", "")
|
event_type = event.get("event_type", "")
|
||||||
data = event.get("data", {})
|
outer = event.get("data", {})
|
||||||
|
# amplifierd wraps event-specific fields in a nested "data" key.
|
||||||
|
# Fall back to the outer dict for tests that pass data directly.
|
||||||
|
data = outer.get("data", outer)
|
||||||
|
|
||||||
if event_type == "content_block:start":
|
if event_type == "content_block:start":
|
||||||
return self._handle_block_start(data)
|
return self._handle_block_start(data)
|
||||||
@@ -62,9 +68,13 @@ class AmplifierdEventTranslator:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _handle_block_start(self, data: dict[str, Any]) -> list[Any]:
|
def _handle_block_start(self, data: dict[str, Any]) -> list[Any]:
|
||||||
"""Handle content_block:start — emit TEXT_MESSAGE_START or TOOL_CALL_START."""
|
"""Handle content_block:start — register block; defer TEXT_MESSAGE_START to first delta.
|
||||||
index = data.get("index", 0)
|
|
||||||
block_type = data.get("type", "text")
|
Supports both amplifierd wire format (``block_index`` / ``block_type``) and the
|
||||||
|
legacy test format (``index`` / ``type``) via fallbacks.
|
||||||
|
"""
|
||||||
|
index = data.get("block_index", data.get("index", 0))
|
||||||
|
block_type = data.get("block_type", data.get("type", "text"))
|
||||||
|
|
||||||
if block_type == "tool_use":
|
if block_type == "tool_use":
|
||||||
tool_id = data.get("id", "")
|
tool_id = data.get("id", "")
|
||||||
@@ -81,21 +91,27 @@ class AmplifierdEventTranslator:
|
|||||||
parent_message_id=self._current_message_id,
|
parent_message_id=self._current_message_id,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
else:
|
elif block_type == "text":
|
||||||
# Text block
|
# Lazy start: don't emit TEXT_MESSAGE_START until we know there's actual content.
|
||||||
message_id = str(uuid.uuid4())
|
message_id = str(uuid.uuid4())
|
||||||
self._blocks[index] = {"type": "text", "message_id": message_id}
|
self._blocks[index] = {"type": "text", "message_id": message_id, "started": False}
|
||||||
self._current_message_id = message_id
|
self._current_message_id = message_id
|
||||||
return [
|
return []
|
||||||
TextMessageStartEvent(
|
else:
|
||||||
message_id=message_id,
|
# thinking, image, or any other block type — skip entirely
|
||||||
role="assistant",
|
self._blocks[index] = {"type": "skip"}
|
||||||
)
|
return []
|
||||||
]
|
|
||||||
|
|
||||||
def _handle_block_delta(self, data: dict[str, Any]) -> list[Any]:
|
def _handle_block_delta(self, data: dict[str, Any]) -> list[Any]:
|
||||||
"""Handle content_block:delta — emit TEXT_MESSAGE_CONTENT or TOOL_CALL_ARGS."""
|
"""Handle content_block:delta — emit TEXT_MESSAGE_CONTENT or TOOL_CALL_ARGS.
|
||||||
index = data.get("index", 0)
|
|
||||||
|
For text blocks, TEXT_MESSAGE_START is emitted lazily here on the first
|
||||||
|
non-empty token so that thinking/empty text blocks produce zero AG-UI events.
|
||||||
|
|
||||||
|
Supports both amplifierd wire format (``block_index``) and legacy test format
|
||||||
|
(``index``) via fallback.
|
||||||
|
"""
|
||||||
|
index = data.get("block_index", data.get("index", 0))
|
||||||
delta = data.get("delta", {})
|
delta = data.get("delta", {})
|
||||||
delta_type = delta.get("type", "")
|
delta_type = delta.get("type", "")
|
||||||
|
|
||||||
@@ -112,6 +128,9 @@ class AmplifierdEventTranslator:
|
|||||||
]
|
]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
if block_info["type"] == "skip":
|
||||||
|
return []
|
||||||
|
|
||||||
if block_info["type"] == "tool_use":
|
if block_info["type"] == "tool_use":
|
||||||
return [
|
return [
|
||||||
ToolCallArgsEvent(
|
ToolCallArgsEvent(
|
||||||
@@ -120,10 +139,22 @@ class AmplifierdEventTranslator:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Text block
|
# Text block — lazy start: emit TEXT_MESSAGE_START on the first real token
|
||||||
token = delta.get("text", "")
|
token = delta.get("text", "")
|
||||||
if not token:
|
if not token:
|
||||||
return []
|
return []
|
||||||
|
if not block_info.get("started"):
|
||||||
|
block_info["started"] = True
|
||||||
|
return [
|
||||||
|
TextMessageStartEvent(
|
||||||
|
message_id=block_info["message_id"],
|
||||||
|
role="assistant",
|
||||||
|
),
|
||||||
|
TextMessageContentEvent(
|
||||||
|
message_id=block_info["message_id"],
|
||||||
|
delta=token,
|
||||||
|
),
|
||||||
|
]
|
||||||
return [
|
return [
|
||||||
TextMessageContentEvent(
|
TextMessageContentEvent(
|
||||||
message_id=block_info["message_id"],
|
message_id=block_info["message_id"],
|
||||||
@@ -132,17 +163,46 @@ class AmplifierdEventTranslator:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def _handle_block_end(self, data: dict[str, Any]) -> list[Any]:
|
def _handle_block_end(self, data: dict[str, Any]) -> list[Any]:
|
||||||
"""Handle content_block:end — emit TEXT_MESSAGE_END or TOOL_CALL_END."""
|
"""Handle content_block:end — emit TEXT_MESSAGE_END or TOOL_CALL_END.
|
||||||
index = data.get("index", 0)
|
|
||||||
|
Supports two modes:
|
||||||
|
- **Streaming mode** (delta events were received): the block was already opened
|
||||||
|
via ``_handle_block_delta``; just close it with TEXT_MESSAGE_END.
|
||||||
|
- **Batch mode** (no delta events, full text in ``block.text``): emit
|
||||||
|
TEXT_MESSAGE_START + TEXT_MESSAGE_CONTENT + TEXT_MESSAGE_END in one shot.
|
||||||
|
This is the format used by amplifierd, which sends complete blocks at end time.
|
||||||
|
|
||||||
|
Text blocks with no content in either mode are silently dropped.
|
||||||
|
|
||||||
|
Supports both amplifierd wire format (``block_index``) and legacy test format
|
||||||
|
(``index``) via fallback.
|
||||||
|
"""
|
||||||
|
index = data.get("block_index", data.get("index", 0))
|
||||||
block_info = self._blocks.pop(index, None)
|
block_info = self._blocks.pop(index, None)
|
||||||
|
|
||||||
if block_info is None:
|
if block_info is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
if block_info["type"] == "skip":
|
||||||
|
return []
|
||||||
|
|
||||||
if block_info["type"] == "tool_use":
|
if block_info["type"] == "tool_use":
|
||||||
return [ToolCallEndEvent(tool_call_id=block_info["tool_id"])]
|
return [ToolCallEndEvent(tool_call_id=block_info["tool_id"])]
|
||||||
else:
|
else:
|
||||||
return [TextMessageEndEvent(message_id=block_info["message_id"])]
|
message_id = block_info["message_id"]
|
||||||
|
if block_info.get("started"):
|
||||||
|
# Streaming mode: deltas already opened the message — just close it.
|
||||||
|
return [TextMessageEndEvent(message_id=message_id)]
|
||||||
|
# Batch mode: full text is in the "block" field (amplifierd wire format).
|
||||||
|
block = data.get("block", {})
|
||||||
|
text = block.get("text", "")
|
||||||
|
if text:
|
||||||
|
return [
|
||||||
|
TextMessageStartEvent(message_id=message_id, role="assistant"),
|
||||||
|
TextMessageContentEvent(message_id=message_id, delta=text),
|
||||||
|
TextMessageEndEvent(message_id=message_id),
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
def _handle_tool_result(self, data: dict[str, Any]) -> list[Any]:
|
def _handle_tool_result(self, data: dict[str, Any]) -> list[Any]:
|
||||||
"""Handle tool:result — emit TOOL_CALL_RESULT with optional MCP App metadata."""
|
"""Handle tool:result — emit TOOL_CALL_RESULT with optional MCP App metadata."""
|
||||||
|
|||||||
@@ -97,11 +97,24 @@ extract current data, and synthesize findings into clear, actionable results. Th
|
|||||||
browser runs persistently — tabs stay open between commands, and the user can watch
|
browser runs persistently — tabs stay open between commands, and the user can watch
|
||||||
and take over at any time via the noVNC view.
|
and take over at any time via the noVNC view.
|
||||||
|
|
||||||
|
## IMPORTANT: Pre-installed Environment
|
||||||
|
|
||||||
|
`playwright-cli` is **already installed** in this container. Do NOT install anything.
|
||||||
|
Do NOT run `npm install`, `pip install`, or any package manager. Just use the commands
|
||||||
|
below directly via the bash tool.
|
||||||
|
|
||||||
## Browser Control (playwright-cli)
|
## Browser Control (playwright-cli)
|
||||||
|
|
||||||
You control the browser using `playwright-cli` with a persistent named session
|
You control the browser using `playwright-cli` via the bash tool. All commands use
|
||||||
(`-s=research`). All commands operate on the same browser session, preserving tabs
|
a persistent named session (`-s=research`), preserving tabs and state across the
|
||||||
and state across the entire conversation.
|
entire conversation.
|
||||||
|
|
||||||
|
**How to invoke:** Every playwright-cli command runs through the bash tool:
|
||||||
|
```
|
||||||
|
bash("playwright-cli -s=research open https://example.com")
|
||||||
|
bash("playwright-cli -s=research snapshot")
|
||||||
|
bash("playwright-cli -s=research click @e5")
|
||||||
|
```
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
@@ -109,9 +122,9 @@ and state across the entire conversation.
|
|||||||
|--------|---------|
|
|--------|---------|
|
||||||
| Open a URL | `playwright-cli -s=research open https://example.com` |
|
| Open a URL | `playwright-cli -s=research open https://example.com` |
|
||||||
| Take a snapshot | `playwright-cli -s=research snapshot` |
|
| Take a snapshot | `playwright-cli -s=research snapshot` |
|
||||||
| Compact snapshot | `playwright-cli -s=research snapshot -ic` |
|
|
||||||
| Click an element | `playwright-cli -s=research click @e5` |
|
| Click an element | `playwright-cli -s=research click @e5` |
|
||||||
| Type text | `playwright-cli -s=research type @e3 "search query"` |
|
| Type text | `playwright-cli -s=research type "search query"` |
|
||||||
|
| Fill a field | `playwright-cli -s=research fill @e3 "search query"` |
|
||||||
| Press a key | `playwright-cli -s=research press Enter` |
|
| Press a key | `playwright-cli -s=research press Enter` |
|
||||||
| Scroll down | `playwright-cli -s=research scroll down` |
|
| Scroll down | `playwright-cli -s=research scroll down` |
|
||||||
| Go back | `playwright-cli -s=research back` |
|
| Go back | `playwright-cli -s=research back` |
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ bundle:
|
|||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
description: General-purpose AI research tool with browser control and artifact generation
|
description: General-purpose AI research tool with browser control and artifact generation
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- module: provider-anthropic
|
||||||
|
source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main
|
||||||
|
config:
|
||||||
|
default_model: claude-sonnet-4-20250514
|
||||||
|
|
||||||
|
session:
|
||||||
|
orchestrator:
|
||||||
|
config:
|
||||||
|
extended_thinking: false
|
||||||
|
|
||||||
includes:
|
includes:
|
||||||
- bundle: git+https://github.com/microsoft/amplifier-foundation@main
|
- bundle: git+https://github.com/microsoft/amplifier-foundation@main
|
||||||
- bundle: research-workbench:behaviors/research-workbench
|
- bundle: research-workbench:behaviors/research-workbench
|
||||||
|
|||||||
@@ -3,22 +3,56 @@
|
|||||||
This session runs inside the **Research Workbench**. You have access to the
|
This session runs inside the **Research Workbench**. You have access to the
|
||||||
following capabilities:
|
following capabilities:
|
||||||
|
|
||||||
## Available Capabilities
|
## CRITICAL: Pre-installed Tools
|
||||||
|
|
||||||
**Browser** — Live Chromium browser controlled via `playwright-cli -s=research`.
|
All tools are **already installed**. Do NOT run npm install, pip install, or any
|
||||||
The browser is persistent across commands and visible to the user via VNC (noVNC
|
package manager. Just use the commands directly via the bash tool.
|
||||||
panel). The user can take over at any time.
|
|
||||||
|
|
||||||
**Artifacts** — Persistent markdown documents written to `artifacts/<session_id>/`.
|
## Browser Control
|
||||||
|
|
||||||
|
Live Chromium browser controlled via `playwright-cli -s=research` through the
|
||||||
|
bash tool. The browser is persistent across commands and visible to the user via
|
||||||
|
the noVNC panel. The user can take over at any time.
|
||||||
|
|
||||||
|
**Quick start — open a site and see what's on it:**
|
||||||
|
```
|
||||||
|
bash("playwright-cli -s=research open https://google.com")
|
||||||
|
bash("playwright-cli -s=research snapshot")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Click an element from the snapshot:**
|
||||||
|
```
|
||||||
|
bash("playwright-cli -s=research click @e5")
|
||||||
|
bash("playwright-cli -s=research snapshot")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Type into a field and submit:**
|
||||||
|
```
|
||||||
|
bash("playwright-cli -s=research fill @e3 \"search query\"")
|
||||||
|
bash("playwright-cli -s=research press Enter")
|
||||||
|
bash("playwright-cli -s=research snapshot")
|
||||||
|
```
|
||||||
|
|
||||||
|
Always snapshot after navigation to get fresh element refs.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
Persistent markdown documents written to `artifacts/<session_id>/`.
|
||||||
They appear in the right panel and survive across sessions.
|
They appear in the right panel and survive across sessions.
|
||||||
|
|
||||||
**A2UI** — Inline interactive controls (filter chips, tables, card grids, forms)
|
## A2UI
|
||||||
|
|
||||||
|
Inline interactive controls (filter chips, tables, card grids, forms)
|
||||||
rendered directly in chat via `a2ui` code blocks.
|
rendered directly in chat via `a2ui` code blocks.
|
||||||
|
|
||||||
**Inline Visuals** — Rich visual output (charts, maps, comparison tables with color
|
## Inline Visuals
|
||||||
|
|
||||||
|
Rich visual output (charts, maps, comparison tables with color
|
||||||
coding) rendered directly in chat via `visual` code blocks.
|
coding) rendered directly in chat via `visual` code blocks.
|
||||||
|
|
||||||
## Full Command Reference
|
## Research Workflow
|
||||||
|
|
||||||
For complete command syntax, workflow guidance, and best practices, see the
|
1. Understand the request — clarify scope and constraints
|
||||||
researcher agent system prompt.
|
2. Plan — identify 2-4 sources to consult
|
||||||
|
3. Browse and extract — use playwright-cli via bash to navigate sites and extract data
|
||||||
|
4. Present — use A2UI, visuals, or artifacts to deliver results
|
||||||
|
|||||||
+4
-1
@@ -21,8 +21,11 @@ sleep 1
|
|||||||
echo "[OK] noVNC on :6080"
|
echo "[OK] noVNC on :6080"
|
||||||
|
|
||||||
# (4) Start amplifierd and wait for it to be healthy
|
# (4) Start amplifierd and wait for it to be healthy
|
||||||
|
# amplifierd uses a 'serve' subcommand; --bundle requires NAME=URI format
|
||||||
cd /app
|
cd /app
|
||||||
amplifierd --port 8410 --bundle /app/bundle/bundle.md &
|
amplifierd serve --port 8410 \
|
||||||
|
--bundle "research-workbench=/app/bundle/bundle.md" \
|
||||||
|
--default-bundle research-workbench &
|
||||||
AMPLIFIERD_PID=$!
|
AMPLIFIERD_PID=$!
|
||||||
|
|
||||||
echo "Waiting for amplifierd..."
|
echo "Waiting for amplifierd..."
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function useChat(sessionId: string | null) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'TEXT_MESSAGE_END': {
|
case 'TEXT_MESSAGE_END': {
|
||||||
// no-op
|
assistantId = null
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'TOOL_CALL_START': {
|
case 'TOOL_CALL_START': {
|
||||||
|
|||||||
+125
-17
@@ -18,21 +18,17 @@ class TestTextBlocks:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_text_block_start(self):
|
def test_text_block_start(self):
|
||||||
"""text_block_start → TEXT_MESSAGE_START with role='assistant' and messageId."""
|
"""text_block_start → no events emitted (lazy: start is deferred to first delta)."""
|
||||||
event = {
|
event = {
|
||||||
"event_type": "content_block:start",
|
"event_type": "content_block:start",
|
||||||
"data": {"index": 0, "type": "text"},
|
"data": {"index": 0, "type": "text"},
|
||||||
}
|
}
|
||||||
result = self.translator.translate(event)
|
result = self.translator.translate(event)
|
||||||
assert len(result) == 1
|
assert result == []
|
||||||
ev = result[0]
|
|
||||||
assert ev.type.value == "TEXT_MESSAGE_START"
|
|
||||||
assert ev.role == "assistant"
|
|
||||||
assert ev.message_id # must have a non-empty message_id
|
|
||||||
|
|
||||||
def test_text_delta(self):
|
def test_text_delta(self):
|
||||||
"""text_delta → TEXT_MESSAGE_CONTENT with delta='Hello '."""
|
"""First text_delta → TEXT_MESSAGE_START then TEXT_MESSAGE_CONTENT (lazy start)."""
|
||||||
# Start the text block first
|
# Start the text block — no event emitted yet
|
||||||
start_event = {
|
start_event = {
|
||||||
"event_type": "content_block:start",
|
"event_type": "content_block:start",
|
||||||
"data": {"index": 0, "type": "text"},
|
"data": {"index": 0, "type": "text"},
|
||||||
@@ -47,19 +43,27 @@ class TestTextBlocks:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
result = self.translator.translate(delta_event)
|
result = self.translator.translate(delta_event)
|
||||||
assert len(result) == 1
|
# Lazy start: first delta emits [TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT]
|
||||||
ev = result[0]
|
assert len(result) == 2
|
||||||
assert ev.type.value == "TEXT_MESSAGE_CONTENT"
|
start_ev, content_ev = result
|
||||||
assert ev.delta == "Hello "
|
assert start_ev.type.value == "TEXT_MESSAGE_START"
|
||||||
|
assert start_ev.role == "assistant"
|
||||||
|
assert start_ev.message_id
|
||||||
|
assert content_ev.type.value == "TEXT_MESSAGE_CONTENT"
|
||||||
|
assert content_ev.delta == "Hello "
|
||||||
|
assert content_ev.message_id == start_ev.message_id
|
||||||
|
|
||||||
def test_text_block_end(self):
|
def test_text_block_end(self):
|
||||||
"""text_block_end → TEXT_MESSAGE_END."""
|
"""text_block_end after content → TEXT_MESSAGE_END with matching messageId."""
|
||||||
# Start the block first
|
# Start block, send a delta (which lazily opens it), then close it
|
||||||
start_event = {
|
self.translator.translate({
|
||||||
"event_type": "content_block:start",
|
"event_type": "content_block:start",
|
||||||
"data": {"index": 0, "type": "text"},
|
"data": {"index": 0, "type": "text"},
|
||||||
}
|
})
|
||||||
self.translator.translate(start_event)
|
self.translator.translate({
|
||||||
|
"event_type": "content_block:delta",
|
||||||
|
"data": {"index": 0, "delta": {"type": "text_delta", "text": "Hi"}},
|
||||||
|
})
|
||||||
|
|
||||||
end_event = {
|
end_event = {
|
||||||
"event_type": "content_block:end",
|
"event_type": "content_block:end",
|
||||||
@@ -195,6 +199,110 @@ class TestToolBlocks:
|
|||||||
assert "structuredContent" not in d
|
assert "structuredContent" not in d
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtendedThinking:
|
||||||
|
"""Tests for extended thinking blocks — must produce zero AG-UI events."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.translator = AmplifierdEventTranslator(
|
||||||
|
run_id="run_1", thread_id="thread_1"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _thinking_start(self, index: int = 0) -> dict:
|
||||||
|
return {
|
||||||
|
"event_type": "content_block:start",
|
||||||
|
"data": {"index": index, "type": "thinking"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _thinking_delta(self, text: str, index: int = 0) -> dict:
|
||||||
|
return {
|
||||||
|
"event_type": "content_block:delta",
|
||||||
|
"data": {"index": index, "delta": {"type": "thinking_delta", "thinking": text}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _thinking_end(self, index: int = 0) -> dict:
|
||||||
|
return {"event_type": "content_block:end", "data": {"index": index}}
|
||||||
|
|
||||||
|
def _text_start(self, index: int = 1) -> dict:
|
||||||
|
return {
|
||||||
|
"event_type": "content_block:start",
|
||||||
|
"data": {"index": index, "type": "text"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _text_delta(self, text: str, index: int = 1) -> dict:
|
||||||
|
return {
|
||||||
|
"event_type": "content_block:delta",
|
||||||
|
"data": {"index": index, "delta": {"type": "text_delta", "text": text}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _text_end(self, index: int = 1) -> dict:
|
||||||
|
return {"event_type": "content_block:end", "data": {"index": index}}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_thinking_block_produces_no_events(self):
|
||||||
|
"""thinking start/delta/end → all return [] (thinking blocks are skipped)."""
|
||||||
|
assert self.translator.translate(self._thinking_start()) == []
|
||||||
|
assert self.translator.translate(self._thinking_delta("Let me reason…")) == []
|
||||||
|
assert self.translator.translate(self._thinking_end()) == []
|
||||||
|
|
||||||
|
def test_empty_text_block_produces_no_events(self):
|
||||||
|
"""text start then immediate end with no deltas → no AG-UI events emitted."""
|
||||||
|
assert self.translator.translate(self._text_start()) == []
|
||||||
|
assert self.translator.translate(self._text_end()) == []
|
||||||
|
|
||||||
|
def test_thinking_then_empty_text_produces_no_events(self):
|
||||||
|
"""Full sequence: thinking block + empty text block → zero AG-UI events total."""
|
||||||
|
events = [
|
||||||
|
self._thinking_start(index=0),
|
||||||
|
self._thinking_delta("I need to think…", index=0),
|
||||||
|
self._thinking_end(index=0),
|
||||||
|
self._text_start(index=1),
|
||||||
|
self._text_end(index=1),
|
||||||
|
]
|
||||||
|
all_results = []
|
||||||
|
for ev in events:
|
||||||
|
all_results.extend(self.translator.translate(ev))
|
||||||
|
assert all_results == []
|
||||||
|
|
||||||
|
def test_thinking_then_nonempty_text_works(self):
|
||||||
|
"""Thinking block (skip) then text block with actual content → START + CONTENT + END."""
|
||||||
|
# Thinking block — all silent
|
||||||
|
self.translator.translate(self._thinking_start(index=0))
|
||||||
|
self.translator.translate(self._thinking_delta("Reasoning…", index=0))
|
||||||
|
self.translator.translate(self._thinking_end(index=0))
|
||||||
|
|
||||||
|
# Text block — lazy open on first delta
|
||||||
|
self.translator.translate(self._text_start(index=1))
|
||||||
|
|
||||||
|
first_delta_result = self.translator.translate(self._text_delta("Hello", index=1))
|
||||||
|
assert len(first_delta_result) == 2
|
||||||
|
start_ev, content_ev = first_delta_result
|
||||||
|
assert start_ev.type.value == "TEXT_MESSAGE_START"
|
||||||
|
assert start_ev.role == "assistant"
|
||||||
|
message_id = start_ev.message_id
|
||||||
|
assert content_ev.type.value == "TEXT_MESSAGE_CONTENT"
|
||||||
|
assert content_ev.delta == "Hello"
|
||||||
|
assert content_ev.message_id == message_id
|
||||||
|
|
||||||
|
# Subsequent delta — only CONTENT (no second START)
|
||||||
|
second_delta_result = self.translator.translate(self._text_delta(" world", index=1))
|
||||||
|
assert len(second_delta_result) == 1
|
||||||
|
assert second_delta_result[0].type.value == "TEXT_MESSAGE_CONTENT"
|
||||||
|
assert second_delta_result[0].delta == " world"
|
||||||
|
|
||||||
|
# End — TEXT_MESSAGE_END because content was started
|
||||||
|
end_result = self.translator.translate(self._text_end(index=1))
|
||||||
|
assert len(end_result) == 1
|
||||||
|
assert end_result[0].type.value == "TEXT_MESSAGE_END"
|
||||||
|
assert end_result[0].message_id == message_id
|
||||||
|
|
||||||
|
|
||||||
class TestLifecycle:
|
class TestLifecycle:
|
||||||
"""Tests for lifecycle events → AG-UI run lifecycle events."""
|
"""Tests for lifecycle events → AG-UI run lifecycle events."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user