feat: extract the portable middleware layer into AgentCore - #29
Merged
Merged
Conversation
Moves everything on ``scripts/contract/closure.py``'s declared candidate list
plus four modules that were already product-neutral but missing from it.
Releases as 0.6.0, which also carries 0.5.0's contents -- that version merged to
main but was never tagged, so it never reached PyPI. CHANGELOG marks it the way
0.2.0 was marked.
Core gains the phase/tool runner it was missing: it already declared
``ExecutionMiddleware`` and the structural ``PhaseMiddlewareChain`` in
``protocols.py`` but shipped no ``MiddlewareChain`` to run them. Plus eleven
portable middlewares, ``components/memory`` (which ``todo`` reads), and two new
Protocols.
``builtins.py`` deliberately stays in the product: it resolves a
``TaskContextStore`` through the registry and hardcodes a research vocabulary
(``report`` / ``assertions`` / ``evidence_cards`` / ``clarified_questions``) when
summarising a phase result. Neither belongs here.
The one real design change is ``TokenAccountingMiddleware.persist_cost``, which
did a SQLAlchemy read-modify-write on the product's ``Task`` row. That is now the
injected ``CostPersister`` protocol, and it finishes work the file had already
started -- it took ``cost_sink`` and ``session_factory`` by injection with a
comment about keeping middleware from importing ``state/`` directly. Splitting
``CostSink`` from ``CostPersister`` is the boundary: ``record`` is synchronous and
runs per call and may be memory-only, ``persist`` runs once and is the only path
that reaches a database. ``summary`` is forwarded from the host's own
``get_summary`` without core inspecting its shape, so the host can evolve it
without a core release. This is a breaking signature change for a caller passing
``session_factory``; the CHANGELOG says so under Consumer action.
``infra/llm/summary_prompt.py`` turned out NOT to need moving, contrary to the
plan and to closure.py's reserved ``core/runtime/loop/compact_prompt.py`` slot.
Core already ships ``runtime/loop/summary_prompt.py`` with the same public names
and strictly more: ``RESEARCH_COMPACTION_PROMPT``, ``HANDOFF_COMPACTION_PROMPT``,
a ``compaction_prompt()`` selector, and a research prompt that preserves exact
search queries. The product's copy is a stale fork. So ``compaction.py`` now
imports core's, and the product copy should be deleted rather than moved.
Fixes found by AgentCore's stricter gates, which the product's config never ran
over these files:
- ``LLMCallContext.metadata``'s factory was unparametrised in an existing core
file. Harmless until something read ``ctx.metadata`` deeply; now that several
modules do, it made every consumer type-check as unknown.
- ``base._is_retryable`` is only an alias for
``agent_core.retry_policy.legacy_retryable``; ``retry`` and
``api_key_rotation`` now import the public name instead of reaching for a
private across modules.
- ``getattr(response, "usage", {}) or {}`` narrowed by ``isinstance`` yields
``dict[Unknown, Unknown]``; those shapes are named explicitly now.
- Bare ``dict`` parameters and ``list[tuple[re.Pattern, str]]`` are parametrised.
- ``[x] + messages`` and an ``if cond: return True / return False`` collapsed --
both provably identical.
Left alone on purpose: ``_record_usage_aggregator``'s three-level cascading
``TypeError`` fallback across three kwarg signatures. Collapsing it is a behavior
change that deserves its own review, so it carries a targeted ``noqa`` and a
note in the boundary doc rather than a silent rewrite.
Tests: four product files ported (``rate_limit`` + ``tool_audit`` +
``TokenBucket``, ``token_accounting``, ``output_repair``, ``api_key_rotation``),
and a new ``test_middleware_seams_shared.py`` covering the three things that had
no tests anywhere -- ``retry``, ``tracing``, and the ``CostPersister`` seam
including its no-op, swallow-failure and missing-``get_summary`` paths.
``test_middleware.py`` stays in the product: it exercises ``builtins`` and needs
``state.event_store.sqlite`` and ``scheduling.process_manager``.
Verified: ruff clean over agent_core/tests/scripts, pyright strict 0 errors,
pytest 1378 passed (1302 + 76), uv build + twine check PASSED, and the 0.6.0
wheel installed into a clean 3.12 venv imports all 160 submodules with the new
surface reachable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves everything on
scripts/contract/closure.py's declared candidate list, plus four modules that were already product-neutral but missing from it. Releases as 0.6.0.0.6.0 also carries 0.5.0's contents. 0.5.0 merged to
mainbut was never tagged, so it never reached PyPI. CHANGELOG marks it**Never published.**, the way 0.2.0 is marked. Downstream only ever needs one pin bump.What core was missing
It already declared
ExecutionMiddlewareand the structuralPhaseMiddlewareChaininprotocols.pybut shipped noMiddlewareChainto run them. That runner arrives here, along with eleven portable middlewares,components/memory(whichtodoreads), and two new Protocols.builtins.pydeliberately stays in the productIt resolves a
TaskContextStorethrough the registry, andafter_phasehardcodes a research vocabulary — it scans the phase result forreport/assertions/evidence_cards/clarified_questions. Neither belongs in a shared package, and it is absent from closure.py's list for that reason.One real design change:
persist_costIt did a SQLAlchemy read-modify-write on the product's
Taskrow. That is now the injectedCostPersisterProtocol.This finishes work the file had already started — it took
cost_sinkandsession_factoryby injection, with a comment about keepingcomponents/middleware/from importingstate/directly. Splitting the two Protocols is the boundary:CostSink.record— synchronous, per call, on the hot path, may be memory-only.CostPersister.persist— once at completion, the only path that reaches a database.summaryis forwarded from the host's ownget_summarywithout core inspecting its shape, so a host can evolve that shape without a core release.Breaking for a caller passing
session_factory— flagged under Consumer action in the CHANGELOG. Both seams still default toNone, which keepspersist_costa no-op on the stateless path.A plan step that turned out to be wrong
The plan said move
infra/llm/summary_prompt.py→runtime/loop/compact_prompt.py, the slot closure.py reserves with a comment. Not needed. Core already shipsruntime/loop/summary_prompt.pywith the same public names and strictly more:RESEARCH_COMPACTION_PROMPT,HANDOFF_COMPACTION_PROMPT, acompaction_prompt()selector, and a research prompt that preserves exact search queries verbatim so future turns don't re-run them.The product's copy is a stale fork — same API, older text, quietly not receiving improvements. So
compaction.pyimports core's, and the product copy gets deleted rather than moved. closure.py's reserved path is stale and should go too.Fixes the stricter gates surfaced
The product never ran pyright strict over these files:
LLMCallContext.metadata's field factory was unparametrised in an existing core file. Harmless while nothing readctx.metadatadeeply; now that several modules do, it made every consumer type-check as unknown.base._is_retryableis only an alias foragent_core.retry_policy.legacy_retryable.retryandapi_key_rotationnow import the public name instead of reaching across modules for a private.getattr(response, "usage", {}) or {}narrowed byisinstanceyieldsdict[Unknown, Unknown]. Those shapes are named explicitly now.dictparameters andlist[tuple[re.Pattern, str]]parametrised.[x] + messages→[x, *messages], andif cond: return True / return False→return cond. Both provably identical.Left alone on purpose
_record_usage_aggregator's three-level cascadingTypeErrorfallback across three differentrecord_llm_callsignatures. Collapsing it is a behavior change that deserves its own review, so it carries a targetednoqa: SIM105with a reason and a note in the boundary doc — not a silent rewrite.Tests
Four product files ported:
rate_limit+tool_audit+TokenBucket,token_accounting,output_repair,api_key_rotation.New
test_middleware_seams_shared.pycovers the three things that had no tests anywhere —retry(back-off growth, the cap, non-retryable declined without sleeping, max-retries),tracing(metadata duration wins over the wall clock, fallback markers surface, a raising backend still returns the response), and theCostPersisterseam (forwards summary + observed model, no-op without either seam, swallows a failing persister, skips a sink with noget_summary).test_middleware.pystays in the product — it exercisesbuiltinsand needsstate.event_store.sqliteandscheduling.process_manager.Verification
ruff check agent_core tests scripts→ cleanpyright agent_core(strict) → 0 errorspytest -q→ 1378 passed (1302 + 76)uv build+twine check→ PASSEDcheck_version_bump.py --base origin/main→Published code changed and version increased 0.5.0 -> 0.6.0.Follow-up
ApodexHarness gets one PR: shims for both this and 0.5.0's cycle/verifier, the
CostPersisterimplementation wired in atworkflows/default_research/runtime.py, deletion of the staleinfra/llm/summary_prompt.py, and pin==0.6.0.