mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
### Why / What / How **Why:** When copilot tools return large outputs (e.g. 3MB+ base64 images from API calls), the agent cannot process them in the E2B sandbox. Three compounding issues prevent seamless file access: 1. The `<tool-output-truncated path="...">` tag uses a bare `path=` attribute that the model confuses with a local filesystem path (it's actually a workspace path) 2. `is_allowed_local_path` rejects `tool-outputs/` directories (only `tool-results/` was allowed) 3. SDK-internal files read via the `Read` tool are not available in the E2B sandbox for `bash_exec` processing **What:** Fixes all three issues so that large tool outputs can be seamlessly read and processed in both host and E2B contexts. **How:** - Changed `path=` → `workspace_path=` in the truncation tag to disambiguate workspace vs filesystem paths - Added `save_to_path` guidance in the retrieval instructions for E2B users - Extended `is_allowed_local_path` to accept both `tool-results` and `tool-outputs` directories - Added automatic bridging: when E2B is active and `Read` accesses an SDK-internal file, the file is automatically copied to `/tmp/<filename>` in the sandbox - Updated system prompting to explain both SDK tool-result bridging and workspace `<tool-output-truncated>` handling ### Changes 🏗️ - **`tools/base.py`**: `_persist_and_summarize` now uses `workspace_path=` attribute and includes `save_to_path` example for E2B processing - **`context.py`**: `is_allowed_local_path` accepts both `tool-results` and `tool-outputs` directory names - **`sdk/e2b_file_tools.py`**: `_handle_read_file` bridges SDK-internal files to `/tmp/` in E2B sandbox; new `_bridge_to_sandbox` helper - **`prompting.py`**: Updated "SDK tool-result files" section and added "Large tool outputs saved to workspace" section - **Tests**: Added `tool-outputs` path validation tests in `context_test.py` and `e2b_file_tools_test.py`; updated `base_test.py` assertion for `workspace_path` ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `poetry run pytest backend/copilot/tools/base_test.py` — all 9 tests pass (persistence, truncation, binary fields) - [x] `poetry run format` and `poetry run lint` pass clean - [x] All pre-commit hooks pass - [ ] `context_test.py`, `e2b_file_tools_test.py`, `security_hooks_test.py` — blocked by pre-existing DB migration issue on worktree (missing `User.subscriptionTier` column); CI will validate these
111 lines
3.5 KiB
Python
111 lines
3.5 KiB
Python
"""Shared test fixtures for copilot SDK tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from unittest.mock import patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from backend.util import json
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session", loop_scope="session", name="server")
|
|
async def _server_noop() -> None:
|
|
"""No-op server stub — SDK tests don't need the full backend."""
|
|
return None
|
|
|
|
|
|
@pytest_asyncio.fixture(
|
|
scope="session", loop_scope="session", autouse=True, name="graph_cleanup"
|
|
)
|
|
async def _graph_cleanup_noop() -> AsyncIterator[None]:
|
|
"""No-op graph cleanup stub."""
|
|
yield
|
|
|
|
|
|
@pytest.fixture()
|
|
def mock_chat_config():
|
|
"""Mock ChatConfig so compact_transcript tests skip real config lookup."""
|
|
with patch(
|
|
"backend.copilot.config.ChatConfig",
|
|
return_value=type("Cfg", (), {"model": "m", "api_key": "k", "base_url": "u"})(),
|
|
):
|
|
yield
|
|
|
|
|
|
def build_test_transcript(pairs: list[tuple[str, str]]) -> str:
|
|
"""Build a minimal valid JSONL transcript from (role, content) pairs.
|
|
|
|
Use this helper in any copilot SDK test that needs a well-formed
|
|
transcript without hitting the real storage layer.
|
|
|
|
Delegates to ``build_structured_transcript`` — plain content strings
|
|
are automatically wrapped in ``[{"type": "text", "text": ...}]`` for
|
|
assistant messages.
|
|
"""
|
|
# Cast widening: tuple[str, str] is structurally compatible with
|
|
# tuple[str, str | list[dict]] but list invariance requires explicit
|
|
# annotation.
|
|
widened: list[tuple[str, str | list[dict]]] = list(pairs)
|
|
return build_structured_transcript(widened)
|
|
|
|
|
|
def build_structured_transcript(
|
|
entries: list[tuple[str, str | list[dict]]],
|
|
) -> str:
|
|
"""Build a JSONL transcript with structured content blocks.
|
|
|
|
Each entry is (role, content) where content is either a plain string
|
|
(for user messages) or a list of content block dicts (for assistant
|
|
messages with thinking/tool_use/text blocks).
|
|
|
|
Example::
|
|
|
|
build_structured_transcript([
|
|
("user", "Hello"),
|
|
("assistant", [
|
|
{"type": "thinking", "thinking": "...", "signature": "sig1"},
|
|
{"type": "text", "text": "Hi there"},
|
|
]),
|
|
])
|
|
"""
|
|
lines: list[str] = []
|
|
last_uuid: str | None = None
|
|
for role, content in entries:
|
|
uid = str(uuid4())
|
|
entry_type = "assistant" if role == "assistant" else "user"
|
|
if role == "assistant" and isinstance(content, list):
|
|
msg: dict = {
|
|
"role": "assistant",
|
|
"model": "claude-test",
|
|
"id": f"msg_{uid[:8]}",
|
|
"type": "message",
|
|
"content": content,
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": None,
|
|
}
|
|
elif role == "assistant":
|
|
msg = {
|
|
"role": "assistant",
|
|
"model": "claude-test",
|
|
"id": f"msg_{uid[:8]}",
|
|
"type": "message",
|
|
"content": [{"type": "text", "text": content}],
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": None,
|
|
}
|
|
else:
|
|
msg = {"role": role, "content": content}
|
|
entry = {
|
|
"type": entry_type,
|
|
"uuid": uid,
|
|
"parentUuid": last_uuid,
|
|
"message": msg,
|
|
}
|
|
lines.append(json.dumps(entry, separators=(",", ":")))
|
|
last_uuid = uid
|
|
return "\n".join(lines) + "\n"
|