Skip to content

Add weak LRU cache and Anthropic streaming tests, refine concurrency#62

Merged
JohnRichard4096 merged 9 commits intomainfrom
update/common
May 2, 2026
Merged

Add weak LRU cache and Anthropic streaming tests, refine concurrency#62
JohnRichard4096 merged 9 commits intomainfrom
update/common

Conversation

@JohnRichard4096
Copy link
Copy Markdown
Member

@JohnRichard4096 JohnRichard4096 commented May 2, 2026

Summary by Sourcery

Add comprehensive Anthropic adapter streaming tests, introduce a weak-reference LRU cache and use it for matcher lock pooling, and refine concurrency, lifecycle, and CI behavior across the chat manager, MCP client, and agent tooling.

New Features:

  • Add extensive unit tests for AnthropicAdapter message, tool, and streaming behaviors.
  • Introduce a generic WeakValueLRUCache utility for weak-reference-based LRU caching.

Enhancements:

  • Refine matcher infrastructure with dataclass-based handler metadata, per-matcher identity/deadline tracking, weakly cached per-event locks, and dead-matcher cleanup during execution.
  • Guard event handling with per-event async locks to avoid concurrent mutation of handler registries.
  • Adjust ChatObject to bind to a specific ChatManager instance and use instance-level locking instead of a global lock for managing running chat objects.
  • Improve MCPClient connection lifecycle with TTL-based deferred close, reusable connections, and explicit immediate-close support.
  • Ensure Anthropic adapter streaming yields the accumulated text content before the final UniResponse and fix agent tool loop to correctly propagate tool execution failure state across multiple calls.

CI:

  • Run CI against a Python version matrix (3.10–3.13), keep Pyright and pytest wired to the selected interpreter, and add a gate job to ensure all matrix builds succeed.

Tests:

  • Add an extensive test suite for WeakValueLRUCache covering lifecycle, eviction, loose mode, and mapping semantics.
  • Expand adapter tests with a full suite for AnthropicAdapter, including content/message/tool conversion and streaming/non-streaming behaviors, while pruning redundant OpenAI streaming tests.

Chores:

  • Bump project version to 0.8.5.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented May 2, 2026

Reviewer's Guide

Adds Anthropic adapter tests and a new WeakValueLRUCache utility, introduces per-event matcher locking and matcher expiry, adds TTL-based lifecycle management for MCPClient connections, refines ChatManager locking and ChatObject binding, fixes agent tool-loop behavior and OpenAI adapter streaming, and enhances CI to run on multiple Python versions and comment test summaries on PRs.

Sequence diagram for matcher event triggering with per-event locking and expiry

sequenceDiagram
    actor App
    participant MatcherFactory
    participant RepoLock as aiologic.Lock
    participant EventRegistry
    participant Handlers as list_FunctionData

    App->>MatcherFactory: trigger_event(event, *args, **kwargs)
    MatcherFactory->>MatcherFactory: event_type = event.get_event_type()
    MatcherFactory->>MatcherFactory: lock = _repo_lock(event_type)
    MatcherFactory->>RepoLock: acquire (async with)
    activate RepoLock
    MatcherFactory->>EventRegistry: get_handlers(event_type)
    EventRegistry-->>MatcherFactory: handlers: defaultdict[int, list[FunctionData]]
    MatcherFactory->>MatcherFactory: priorities = sorted(handlers.keys())
    loop for each priority
        MatcherFactory->>Handlers: matcher_list = handlers[priority]
        MatcherFactory->>MatcherFactory: _simple_run(matcher_list, event, ...)
        activate MatcherFactory
        loop for each func in matcher_list
            MatcherFactory->>MatcherFactory: matcher = func.matcher
            MatcherFactory->>Matcher: dead?
            alt matcher.dead is True
                MatcherFactory->>MatcherFactory: add func to _dead_to_remove
                MatcherFactory-->>MatcherFactory: continue
            else matcher is alive
                MatcherFactory->>MatcherFactory: build session_args, session_kwargs
                MatcherFactory->>func.function: await handler(...)
                func.function-->>MatcherFactory: result or exception
            end
        end
        MatcherFactory->>MatcherFactory: remove _dead_to_remove from matcher_list
        alt matcher.block is True
            MatcherFactory-->>MatcherFactory: return False (stop further priorities)
        else
            MatcherFactory-->>MatcherFactory: continue
        end
        deactivate MatcherFactory
    end
    MatcherFactory->>RepoLock: release
    deactivate RepoLock
    MatcherFactory-->>App: completed
Loading

File-Level Changes

Change Details Files
Introduce WeakValueLRUCache utility and use it for per-category async locking in matcher system, with matcher lifecycle management.
  • Add WeakValueLRUCache implementation with weak-value LRU semantics and extensive unit tests.
  • Refactor FunctionData to a dataclass and extend Matcher to be hashable with id and dead/dead_at semantics.
  • Add a WeakValueLRUCache-backed lock pool in MatcherFactory and guard trigger_event with per-event async locks.
  • Update _simple_run to skip and prune dead matchers and adjust control flow to respect matcher.block after cleanup.
src/amrita_core/weakcache.py
tests/test_weakcache.py
src/amrita_core/hook/matcher.py
Extend Anthropic adapter support and adjust OpenAI adapter streaming behavior.
  • Add comprehensive tests for AnthropicAdapter message/content/tool conversion and streaming/non-streaming call_api behavior, including tool call handling.
  • In Anthropic streaming path, yield the accumulated text content before yielding the final UniResponse to match OpenAI behavior.
tests/test_adapter.py
src/amrita_core/builtins/adapter.py
Improve MCPClient connection lifecycle with TTL-based deferred closing and reuse safeguards.
  • Add connection_ttl parameter, validation, and internal state for close scheduling in MCPClient.
  • Introduce close (TTL-scheduled), close_no_wait (immediate), and internal _clean_waitter helpers, and change aenter/aexit and _connect to respect pending close tasks.
src/amrita_core/tools/mcp.py
Localize ChatManager locking and bind ChatObject instances to a specific manager.
  • Remove global LOCK in chatmanager and replace with per-ChatManager lock instance used by clean_chat_objects and add_chat_object.
  • Allow ChatObject to accept an explicit ChatManager, store it, and use it for registration, cleanup, and metadata updates instead of the global chat_manager.
src/amrita_core/chatmanager.py
Fix tool execution loop error propagation and bump project version.
  • Change Agent._execute_tool_loop to aggregate per-tool call failure into a return flag instead of early-returning on first error, ensuring subsequent tool notifications still run.
  • Bump project version from 0.8.4.1 to 0.8.5.
src/amrita_core/builtins/agent.py
pyproject.toml
Enhance CI to test multiple Python versions and comment summarized test results on PRs.
  • Run CI matrix over Python 3.10–3.13 using uv for specific versions instead of default install.
  • Grant write permissions on pull requests and add a github-script step that parses junit XML, computes basic metrics, and posts a test summary comment per matrix entry.
.github/workflows/CI.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • The new FunctionData dataclass still uses pydantic.Field and a custom model_dump based on asdict; if callers relied on Pydantic model semantics (validation, aliases, model_dump options, etc.) this is a breaking change—consider either keeping it as a Pydantic model or fully switching to dataclasses.field and updating any callers accordingly.
  • The Matcher.dead property uses datetime.datetime.now() without a timezone, while other parts of the code (e.g., ChatObject) use UTC-aware datetimes; consider using a consistent timezone-aware approach to avoid subtle comparison issues between naive and aware datetimes.
  • In WeakValueLRUCache.__len__, the implementation returns len(self._cache) without cleaning expired entries, but the docstring claims it returns the number of non-expired items—either adjust the implementation to filter out expired entries or update the docstring to reflect the actual behavior.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `FunctionData` dataclass still uses `pydantic.Field` and a custom `model_dump` based on `asdict`; if callers relied on Pydantic model semantics (validation, aliases, `model_dump` options, etc.) this is a breaking change—consider either keeping it as a Pydantic model or fully switching to `dataclasses.field` and updating any callers accordingly.
- The `Matcher.dead` property uses `datetime.datetime.now()` without a timezone, while other parts of the code (e.g., `ChatObject`) use UTC-aware datetimes; consider using a consistent timezone-aware approach to avoid subtle comparison issues between naive and aware datetimes.
- In `WeakValueLRUCache.__len__`, the implementation returns `len(self._cache)` without cleaning expired entries, but the docstring claims it returns the number of non-expired items—either adjust the implementation to filter out expired entries or update the docstring to reflect the actual behavior.

## Individual Comments

### Comment 1
<location path="src/amrita_core/hook/matcher.py" line_range="387-396" />
<code_context>
         Returns:
             bool: Should continue to run.
         """
+        _dead_to_remove: list[FunctionData] = []
         for func in matcher_list:
+            matcher: Matcher = func.matcher
</code_context>
<issue_to_address>
**issue (bug_risk):** Removing dead handlers from `matcher_list` while iterating can raise errors and/or skip handlers.

Because `_dead_to_remove` is populated inside the loop and removals happen in the `finally` of each iteration:

1. You mutate `matcher_list` while iterating over it, which can skip handlers or cause unexpected errors.
2. `_dead_to_remove` is never cleared, so later iterations may attempt to remove already-removed entries, raising `ValueError`.

Consider iterating over a snapshot and doing the cleanup once after the loop, for example:

```python
_dead_to_remove: list[FunctionData] = []
for func in list(matcher_list):
    matcher: Matcher = func.matcher
    if matcher.dead:
        _dead_to_remove.append(func)
        continue
    ...  # existing handler logic

for dead_func in _dead_to_remove:
    if dead_func in matcher_list:
        matcher_list.remove(dead_func)
```
</issue_to_address>

### Comment 2
<location path="src/amrita_core/hook/matcher.py" line_range="144-146" />
<code_context>
         """
         raise PassException()  # pragma: no cover

+    @property
+    def dead(self) -> bool:
+        return self._dead_at is not None and self._dead_at < datetime.datetime.now()
+

</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider timezone-aware comparison in `Matcher.dead` to avoid subtle datetime bugs.

If `_dead_at` is ever timezone-aware (e.g. UTC), comparing it to `datetime.datetime.now()` (naive) will raise a `TypeError` when mixing aware and naive datetimes. To avoid this, either always store `_dead_at` as UTC-aware and compare with `datetime.now(timezone.utc)`, or derive `now` using the same `tzinfo` as `_dead_at` so both sides of the comparison are consistent.

```suggestion
    @property
    def dead(self) -> bool:
        if self._dead_at is None:
            return False

        is_aware = (
            self._dead_at.tzinfo is not None
            and self._dead_at.tzinfo.utcoffset(self._dead_at) is not None
        )

        if is_aware:
            now = datetime.datetime.now(self._dead_at.tzinfo)
        else:
            now = datetime.datetime.now()

        return self._dead_at < now
```
</issue_to_address>

### Comment 3
<location path="src/amrita_core/tools/mcp.py" line_range="97-101" />
<code_context>
         Args:
             update_tools (bool, optional): whether to update the tool list. Defaults to False.
         """
-        if self.mcp_client is not None:
+        if self.mcp_client is not None and not self._close_waitter:
             raise RuntimeError("MCP Server is already connected!")
+        else:
+            await self._clean_waitter()

         server_script: MCP_SERVER_SCRIPT_TYPE = self.server_script
</code_context>
<issue_to_address>
**issue (bug_risk):** Reconnecting while a TTL close is scheduled cancels the timer but leaves the previous connection open.

With the new condition, when `self.mcp_client is not None` and `self._close_waitter` is set, `_connect` bypasses the `RuntimeError` and calls `_clean_waitter()`. `_clean_waitter()` cancels the TTL task but does not close the existing `mcp_client` before you assign a new `Client`, so the original connection can be leaked.

You should close any existing `mcp_client` when cancelling a pending TTL close and reconnecting, e.g. by calling `await self._close()` before creating a new client, or by extending `_clean_waitter()` so that it both cancels the TTL and ensures there is at most one active connection.
</issue_to_address>

### Comment 4
<location path="src/amrita_core/weakcache.py" line_range="185-187" />
<code_context>
+            return False
+        return self._cache[key]() is not None
+
+    def __len__(self) -> int:
+        """!!!This will return the number of non-expired items in the cache.!!!"""
+        return len(self._cache)
+
+    def __iter__(self) -> Iterator[K]:
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The `__len__` implementation counts entries without cleaning expired weakrefs, contradicting the docstring.

Because expired entries stay in `_cache` until cleanup, `len(self._cache)` can over-count and conflicts with the docstring’s promise.

You could either:
- Make `__len__` clean up expired entries before counting (like `size()`), or
- Update the docstring to state that `__len__` returns the raw mapping size and recommend `size()` for live-entry counts.

It may also be worth checking whether methods like `is_full()` should use `size()` instead of `len(self._cache)`, depending on your intended semantics.

Suggested implementation:

```python
    def __len__(self) -> int:
        """Return the number of non-expired items in the cache."""
        count = 0
        for key in list(self._cache.keys()):
            if self._cleanup_key_if_expired(key):
                count += 1
        return count

```

If this class exposes a `size()` method elsewhere in the file, you may want to:
1. Implement `size()` in terms of `__len__` (e.g. `return len(self)`) to keep the semantics consistent.
2. Review any `is_full()` or similar methods in this class and decide whether they should use `len(self)` (live entries) instead of `len(self._cache)` (raw mapping size), based on the intended behavior.
</issue_to_address>

### Comment 5
<location path="tests/test_weakcache.py" line_range="35-45" />
<code_context>
+        with pytest.raises(ValueError, match="Capacity must be a positive integer"):
+            WeakValueLRUCache(capacity=-1)
+
+    def test_init_with_zero_capacity(self):
+        """Test initialization with zero capacity"""
+        cache = WeakValueLRUCache(capacity=0)
+        assert cache.capacity == 0
+        assert len(cache) == 0
+
+    def test_init_with_positive_capacity(self):
</code_context>
<issue_to_address>
**suggestion (testing):** There is no test covering `WeakValueLRUCache` behavior when `capacity=0` and items are inserted.

You only assert that a zero-capacity cache initializes correctly, but don’t exercise `put`/`get` under this configuration. Since `capacity=0` is a corner case for the eviction logic in `WeakValueLRUCache.put`, please add a test that inserts an item and asserts the intended behavior (e.g., cache remains empty and `get` returns `None`), so the expected semantics for zero capacity are clear and guarded by tests.

```suggestion
    def test_init_with_zero_capacity(self):
        """Test initialization with zero capacity"""
        cache = WeakValueLRUCache(capacity=0)
        assert cache.capacity == 0
        assert len(cache) == 0

    def test_zero_capacity_put_and_get(self):
        """Test behavior of put/get when cache has zero capacity"""
        cache = WeakValueLRUCache(capacity=0)
        obj = TestObject("value")
        cache.put("key", obj)

        # Cache should remain empty despite the put
        assert len(cache) == 0
        assert cache.get("key") is None

    def test_init_with_positive_capacity(self):
        """Test normal initialization"""
        cache = WeakValueLRUCache(capacity=5)
        assert cache.capacity == 5
        assert len(cache) == 0
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/amrita_core/hook/matcher.py Outdated
Comment thread src/amrita_core/hook/matcher.py
Comment thread src/amrita_core/tools/mcp.py
Comment thread src/amrita_core/weakcache.py
Comment thread tests/test_weakcache.py
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 2, 2026

Deploying amritacore with  Cloudflare Pages  Cloudflare Pages

Latest commit: f84337e
Status: ✅  Deploy successful!
Preview URL: https://b917d19e.amritacore.pages.dev
Branch Preview URL: https://update-common.amritacore.pages.dev

View logs

Co-authored-by: Copilot <copilot@github.com>
@JohnRichard4096
Copy link
Copy Markdown
Member Author

@sourcery-ai title

@sourcery-ai sourcery-ai Bot changed the title Update/common Add Anthropic adapter tests and weak LRU cache, refine concurrency May 2, 2026
@JohnRichard4096
Copy link
Copy Markdown
Member Author

@sourcery-ai title

@JohnRichard4096
Copy link
Copy Markdown
Member Author

@sourcery-ai summary

@sourcery-ai sourcery-ai Bot changed the title Add Anthropic adapter tests and weak LRU cache, refine concurrency Add weak LRU cache and Anthropic streaming tests, refine concurrency May 2, 2026
@JohnRichard4096 JohnRichard4096 merged commit dead5e3 into main May 2, 2026
6 checks passed
@JohnRichard4096 JohnRichard4096 deleted the update/common branch May 2, 2026 03:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant