Skip to content
Closed
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
**An open-source, production-ready Generative AI platform for institutions**
*Built by Boise State University, designed for everyone.*

[![Release](https://img.shields.io/badge/Release-v1.0.0--beta.25-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md)
[![Release](https://img.shields.io/badge/Release-v1.0.0--beta.26-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md)
[![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml)

![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white)
Expand Down Expand Up @@ -260,7 +260,7 @@ agentcore-public-stack/

See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release.

**Current release:** v1.0.0-beta.25
**Current release:** v1.0.0-beta.26

---

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0-beta.25
1.0.0-beta.26
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "agentcore-stack"
version = "1.0.0-beta.25"
version = "1.0.0-beta.26"
requires-python = ">=3.10"
description = "Multi-agent conversational AI system with AWS Bedrock AgentCore"
readme = "README.md"
Expand Down
1 change: 1 addition & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ addopts =
--strict-markers
--disable-warnings
--import-mode=importlib
asyncio_mode = auto

[coverage:run]
source = src
Expand Down
317 changes: 317 additions & 0 deletions backend/scripts/verify_spreadsheet_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
Local verification script for the async fix in list_spreadsheets_tool (issue #260).

Run from the backend/ directory:
python scripts/verify_spreadsheet_async.py

No AWS credentials required — all external calls are mocked.
Works on any plain Python 3.9+ install with zero extra dependencies.

Checks:
1. list_spreadsheets @tool is a coroutine function (async def).
2. _get_session_files directly awaits the repository (no ThreadPoolExecutor).
3. _get_kb_files uses asyncio.to_thread (event loop stays free during DynamoDB call).
4. End-to-end: the tool correctly lists files from both KB and session sources.
5. Regression: running the tool concurrently does not deadlock or raise RuntimeError.
"""

import asyncio
import importlib.util
import inspect
import os
import sys
import types
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock

# ---------------------------------------------------------------------------
# Resolve the path to the module under test
# ---------------------------------------------------------------------------
_BACKEND = Path(__file__).resolve().parent.parent # backend/
_MODULE_PATH = (
_BACKEND / "src" / "agents" / "builtin_tools"
/ "spreadsheet_analysis" / "list_spreadsheets_tool.py"
)
assert _MODULE_PATH.exists(), f"Cannot find module: {_MODULE_PATH}"

# ---------------------------------------------------------------------------
# Stub ALL external dependencies before loading the module so no __init__.py
# chain is triggered.
# ---------------------------------------------------------------------------


def _stub(name: str) -> types.ModuleType:
return types.ModuleType(name)


def _identity(fn): # stand-in for @tool
return fn


# strands
_strands = _stub("strands")
_strands.tool = _identity # type: ignore
sys.modules["strands"] = _strands

# boto3
_boto3 = _stub("boto3")
_boto3.resource = MagicMock() # type: ignore
sys.modules["boto3"] = _boto3

# apis.*
for _pkg in ["apis", "apis.shared", "apis.shared.files"]:
sys.modules.setdefault(_pkg, _stub(_pkg))

_models_stub = _stub("apis.shared.files.models")
_models_stub.is_tabular_file = lambda fn, mime: fn.endswith((".csv", ".xlsx")) # type: ignore
sys.modules["apis.shared.files.models"] = _models_stub

_repo_stub = _stub("apis.shared.files.repository")
_repo_stub.get_file_upload_repository = MagicMock() # type: ignore
sys.modules["apis.shared.files.repository"] = _repo_stub

# agents.* parent stubs — needed so the module name resolves consistently
for _pkg in ["agents", "agents.builtin_tools", "agents.builtin_tools.spreadsheet_analysis"]:
sys.modules.setdefault(_pkg, _stub(_pkg))

# ---------------------------------------------------------------------------
# Load the module directly from its file path (bypasses agents/__init__.py)
# ---------------------------------------------------------------------------
_MOD_NAME = "agents.builtin_tools.spreadsheet_analysis.list_spreadsheets_tool"
_spec = importlib.util.spec_from_file_location(_MOD_NAME, _MODULE_PATH)
assert _spec and _spec.loader
_mod = importlib.util.module_from_spec(_spec)
sys.modules[_MOD_NAME] = _mod
_spec.loader.exec_module(_mod) # type: ignore

# Convenience aliases
make_list_spreadsheets_tool = _mod.make_list_spreadsheets_tool
_get_kb_files = _mod._get_kb_files
_get_session_files = _mod._get_session_files

# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------

PASS = "\033[92m✓\033[0m"
FAIL = "\033[91m✗\033[0m"
_failures: List[str] = []


def check(name: str, ok: bool, detail: str = "") -> None:
if ok:
print(f" {PASS} {name}")
else:
print(f" {FAIL} {name}" + (f" — {detail}" if detail else ""))
_failures.append(name)


@dataclass
class _FakeFile:
"""Minimal stand-in for a FileMetadata object returned by the repository."""
filename: str
mime_type: str
size_bytes: int
upload_id: str
s3_key: str
s3_bucket: str


# ---------------------------------------------------------------------------
# Check 1 — tool is a coroutine function
# ---------------------------------------------------------------------------


def verify_tool_is_async() -> None:
print("\n[1] list_spreadsheets @tool must be async")
fn = make_list_spreadsheets_tool(assistant_id=None, session_id="s1", user_id="u1")
check(
"make_list_spreadsheets_tool() returns a coroutine function",
inspect.iscoroutinefunction(fn),
"tool is still sync — Strands cannot await it",
)


# ---------------------------------------------------------------------------
# Check 2 — _get_session_files directly awaits the repository
# ---------------------------------------------------------------------------


async def verify_session_files_awaits_repo() -> None:
print("\n[2] _get_session_files must directly await the async repository")

csv_file = _FakeFile("sales.csv", "text/csv", 8192, "u1", "s/sales.csv", "bkt")
mock_repo = MagicMock()
mock_repo.list_session_files = AsyncMock(return_value=[csv_file])

# _get_session_files does a lazy `from apis.shared.files.repository import ...`
# so we inject directly into the already-registered stub module.
_orig_factory = _repo_stub.get_file_upload_repository
_orig_itf = _models_stub.is_tabular_file
_repo_stub.get_file_upload_repository = MagicMock(return_value=mock_repo)
_models_stub.is_tabular_file = lambda fn, mime: True # type: ignore

try:
files = await _get_session_files("session-1")
finally:
_repo_stub.get_file_upload_repository = _orig_factory
_models_stub.is_tabular_file = _orig_itf

check(
"repo.list_session_files was awaited exactly once",
mock_repo.list_session_files.await_count == 1,
f"await_count={mock_repo.list_session_files.await_count}",
)
check("returned 1 tabular file", len(files) == 1, f"got {len(files)}")
check("source is 'chat_attachment'", files[0]["source"] == "chat_attachment")


# ---------------------------------------------------------------------------
# Check 3 — _get_kb_files uses asyncio.to_thread
# ---------------------------------------------------------------------------


async def verify_kb_files_uses_to_thread() -> None:
print("\n[3] _get_kb_files must use asyncio.to_thread for the boto3 call")

calls: List[str] = []
fake_items = [{
"status": "complete", "filename": "ledger.xlsx",
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"sizeBytes": 4096, "documentId": "doc-1", "s3Key": "ast-1/ledger.xlsx",
}]
mock_table = MagicMock()
mock_table.query.return_value = {"Items": fake_items}
mock_dynamodb = MagicMock()
mock_dynamodb.Table.return_value = mock_table

# Swap boto3.resource on the stub
_orig_resource = _boto3.resource
_boto3.resource = MagicMock(return_value=mock_dynamodb) # type: ignore

# Spy on asyncio.to_thread via the module's own asyncio reference
_mod_asyncio = _mod.asyncio # type: ignore
_orig_to_thread = _mod_asyncio.to_thread

async def _spy(fn, *args, **kwargs):
calls.append("to_thread")
return fn(*args, **kwargs)

_mod_asyncio.to_thread = _spy

_orig_itf = _models_stub.is_tabular_file
_models_stub.is_tabular_file = lambda fn, mime: True # type: ignore

_old_env = os.environ.get("DYNAMODB_ASSISTANTS_TABLE_NAME")
os.environ["DYNAMODB_ASSISTANTS_TABLE_NAME"] = "AssistantsTable"

try:
files = await _get_kb_files("ast-1")
finally:
_boto3.resource = _orig_resource # type: ignore
_mod_asyncio.to_thread = _orig_to_thread
_models_stub.is_tabular_file = _orig_itf
if _old_env is None:
os.environ.pop("DYNAMODB_ASSISTANTS_TABLE_NAME", None)
else:
os.environ["DYNAMODB_ASSISTANTS_TABLE_NAME"] = _old_env

check(
"asyncio.to_thread was called (blocking IO offloaded from event loop)",
"to_thread" in calls,
"boto3 call ran directly on the event loop — event loop was blocked",
)
check("returned 1 KB file", len(files) == 1, f"got {len(files)}")
check("source is 'knowledge_base'", files[0]["source"] == "knowledge_base")


# ---------------------------------------------------------------------------
# Check 4 — end-to-end tool execution
# ---------------------------------------------------------------------------


async def verify_end_to_end() -> None:
print("\n[4] End-to-end: tool returns combined KB + session files")

kb = {"filename": "ledger.csv", "source": "knowledge_base", "size_bytes": 2048,
"content_type": "text/csv", "document_id": "d1", "s3_key": "k1"}
sess = {"filename": "budget.csv", "source": "chat_attachment", "size_bytes": 1024,
"content_type": "text/csv", "document_id": "u1", "s3_key": "k2", "s3_bucket": "b"}

_orig_kb, _orig_sess = _mod._get_kb_files, _mod._get_session_files # type: ignore
_mod._get_kb_files = AsyncMock(return_value=[kb]) # type: ignore
_mod._get_session_files = AsyncMock(return_value=[sess]) # type: ignore

try:
fn = make_list_spreadsheets_tool(assistant_id="ast-1", session_id="s1", user_id="u1")
result = await fn()
finally:
_mod._get_kb_files = _orig_kb # type: ignore
_mod._get_session_files = _orig_sess # type: ignore

check("status is 'success'", result["status"] == "success")
files = result.get("files", [])
check("2 files returned", len(files) == 2, f"got {len(files)}")
check("both files present", {f["filename"] for f in files} == {"ledger.csv", "budget.csv"})


# ---------------------------------------------------------------------------
# Check 5 — concurrent calls don't deadlock
# ---------------------------------------------------------------------------


async def verify_no_deadlock_under_concurrency() -> None:
print("\n[5] Concurrent tool calls must not deadlock")

_orig_kb, _orig_sess = _mod._get_kb_files, _mod._get_session_files # type: ignore
_mod._get_kb_files = AsyncMock(return_value=[]) # type: ignore
_mod._get_session_files = AsyncMock(return_value=[]) # type: ignore

try:
fn = make_list_spreadsheets_tool(assistant_id=None, session_id="s1", user_id="u1")
try:
results = await asyncio.wait_for(
asyncio.gather(*[fn() for _ in range(5)]),
timeout=5.0,
)
check("5 concurrent calls completed without deadlock", len(results) == 5)
except asyncio.TimeoutError:
check("5 concurrent calls completed without deadlock",
False, "TIMEOUT — likely deadlock")
finally:
_mod._get_kb_files = _orig_kb # type: ignore
_mod._get_session_files = _orig_sess # type: ignore


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


async def _main() -> None:
print("=" * 60)
print(" Async fix verification — list_spreadsheets_tool (#260)")
print("=" * 60)

verify_tool_is_async()
await verify_session_files_awaits_repo()
await verify_kb_files_uses_to_thread()
await verify_end_to_end()
await verify_no_deadlock_under_concurrency()

print("\n" + "=" * 60)
if _failures:
print(f" {FAIL} {len(_failures)} check(s) FAILED:")
for f in _failures:
print(f" - {f}")
sys.exit(1)
else:
print(f" {PASS} All checks passed — async fix is working correctly.")
print("=" * 60 + "\n")


if __name__ == "__main__":
asyncio.run(_main())
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ def make_analyze_tool(
"""Create an analyze_spreadsheet tool bound to the given context."""

@tool
def analyze_spreadsheet(
async def analyze_spreadsheet(
filename: str,
python_code: str,
output_filename: Optional[str] = None,
Expand Down Expand Up @@ -334,7 +334,7 @@ def analyze_spreadsheet(
return {"content": [{"text": "❌ Code Interpreter is not configured. Contact your administrator."}], "status": "error"}

# 2. Find the file in accessible sources
file_info = _find_file(filename, assistant_id, session_id)
file_info = await _find_file(filename, assistant_id, session_id)
if not file_info:
return {"content": [{"text": f"❌ File '{filename}' not found or not accessible. Use list_spreadsheets to see available files."}], "status": "error"}

Expand Down Expand Up @@ -550,7 +550,7 @@ def analyze_spreadsheet(
return analyze_spreadsheet


def _find_file(filename: str, assistant_id: Optional[str], session_id: str) -> Optional[Dict[str, Any]]:
async def _find_file(filename: str, assistant_id: Optional[str], session_id: str) -> Optional[Dict[str, Any]]:
"""Find a file by name in accessible sources. Returns file info or None.

Matches are tolerant to XLSX ↔ CSV aliasing: if the model asks for
Expand All @@ -562,8 +562,8 @@ def _find_file(filename: str, assistant_id: Optional[str], session_id: str) -> O
"""
candidates: list[Dict[str, Any]] = []
if assistant_id:
candidates.extend(_get_kb_files(assistant_id))
candidates.extend(_get_session_files(session_id))
candidates.extend(await _get_kb_files(assistant_id))
candidates.extend(await _get_session_files(session_id))

target_lower = filename.lower()
target_stem, _ = os.path.splitext(target_lower)
Expand Down
Loading