diff --git a/.claude/launch.json b/.claude/launch.json index 9492f43cd..58c46cc6d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,28 +4,39 @@ { "name": "frontend", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "start"], + "runtimeArgs": [ + "run", + "start" + ], "port": 4200, - "cwd": "frontend/ai.client" + "cwd": "frontend/ai.client", + "autoPort": false }, { "name": "app-api", "runtimeExecutable": "/Users/philmerrell/Repos/agentcore-public-stack/backend/.venv/bin/python", - "runtimeArgs": ["main.py"], + "runtimeArgs": [ + "main.py" + ], "port": 8000, "cwd": "backend/src/apis/app_api" }, { "name": "inference-api", "runtimeExecutable": "/Users/philmerrell/Repos/agentcore-public-stack/backend/.venv/bin/python", - "runtimeArgs": ["main.py"], + "runtimeArgs": [ + "main.py" + ], "port": 8001, "cwd": "backend/src/apis/inference_api" }, { "name": "docs-site", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], + "runtimeArgs": [ + "run", + "dev" + ], "port": 4321, "cwd": "docs-site" } diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 755f3bca0..5d76d83bf 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -76,6 +76,20 @@ ARG BUILDX_VERSION=0.30.1 ARG BUILDX_SHA256_AMD64=c37114fcd034025ec68e224657c8a5a850df472ded3ddcbca75ad3a7ebb9710d ARG BUILDX_SHA256_ARM64=31d012d52d6df68aef4b55db62330967b562811f0de30cdfaa4505f314797c76 +# actionlint — static checker for GitHub Actions workflows. +# +# Earns its place because this repo's YAML has a documented, repeatedly-hit +# footgun: `vars.*` / `secrets.*` in a workflow-level `env:` silently resolve +# to empty strings, and nothing fails — the deploy just gets a blank value. +# actionlint checks expression syntax, context availability, and job/needs +# wiring, so that class of error surfaces locally instead of in a deploy. +# +# Hashes are the upstream published values from +# actionlint_1.7.12_checksums.txt. +ARG ACTIONLINT_VERSION=1.7.12 +ARG ACTIONLINT_SHA256_AMD64=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 +ARG ACTIONLINT_SHA256_ARM64=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 + # uv — Python package and toolchain manager. Pinned to match # backend/Dockerfile.app-api and backend/Dockerfile.inference-api. ARG UV_VERSION=0.7.12 @@ -136,6 +150,7 @@ RUN set -eux; \ apt-get install -y --no-install-recommends \ ca-certificates curl wget gnupg sudo bash less procps \ git git-lfs jq unzip xz-utils zip make \ + shellcheck \ build-essential pkg-config \ libssl-dev libffi-dev zlib1g-dev \ locales tzdata \ @@ -239,6 +254,30 @@ RUN set -eux; \ docker --version; \ docker buildx version +# ----------------------------------------------------------------------------- +# actionlint — GitHub Actions workflow linter +# +# Downloaded from the upstream release and verified against the sha256 embedded +# above. Pairs with shellcheck (installed via apt in the base layer): actionlint +# shells out to shellcheck to lint `run:` blocks, so having both means workflow +# scripts get checked too, not just the YAML around them. +# ----------------------------------------------------------------------------- +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) actionlint_arch=amd64; actionlint_sha="${ACTIONLINT_SHA256_AMD64}" ;; \ + arm64) actionlint_arch=arm64; actionlint_sha="${ACTIONLINT_SHA256_ARM64}" ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + cd /tmp; \ + curl -fsSLo actionlint.tgz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_${actionlint_arch}.tar.gz"; \ + echo "${actionlint_sha} actionlint.tgz" | sha256sum -c -; \ + tar -xzf actionlint.tgz actionlint; \ + install -m 0755 actionlint /usr/local/bin/actionlint; \ + rm -f actionlint actionlint.tgz; \ + actionlint --version; \ + shellcheck --version + # ----------------------------------------------------------------------------- # uv — Python package and toolchain manager # @@ -343,6 +382,8 @@ HEALTHCHECK --interval=5m --timeout=15s --start-period=5s --retries=2 \ aws --version && \ cdk --version && \ docker --version && \ + actionlint --version && \ + shellcheck --version && \ git --version' >/dev/null 2>&1 || exit 1 # Default to an interactive shell. Override with -- to run a one-off command. diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 1704abd56..a825f7df6 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -19,6 +19,8 @@ For agent execution rules and the workspace-path map, see | AWS CDK CLI | 2.1128.0 | Matches `infrastructure/package.json` | | Docker CLI (client only) | 29.4.3 | sha256-verified static binary | | Docker buildx (CLI plugin) | 0.30.1 | sha256-verified GitHub release | +| actionlint (workflow linter) | 1.7.12 | sha256-verified GitHub release | +| ShellCheck (shell linter) | 0.9.0 | Ubuntu 24.04 apt package | | Playwright chromium runtime | n/a | Apt deps for Playwright 1.59.x | > All artifacts downloaded over the network during the build are verified diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index d8e60114a..bc27be554 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -240,7 +240,7 @@ jobs: build-kb-migration: name: Build kb-migration image needs: test-backend - # Native ARM64 runner — all four kb-migration Lambdas are arm64 (see the + # Native ARM64 runner — all five kb-migration Lambdas are arm64 (see the # managed-kb CDK construct), matching the kb-sync pattern. runs-on: ubuntu-24.04-arm environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'development' }} @@ -275,10 +275,10 @@ jobs: deploy-kb-migration-code: name: Deploy kb-migration Lambda images - # ONE image, FOUR functions: dispatcher, worker, reconciler and ingestion - # consumer share the kb-migration image and differ only in - # ImageConfig.Command (CDK-owned), so a single job points all four at the - # freshly-built tag. + # ONE image, FIVE functions: dispatcher, worker, reconciler, document + # reconciler and ingestion consumer share the kb-migration image and differ + # only in ImageConfig.Command (CDK-owned), so a single job points all five at + # the freshly-built tag. # # This is the job that replaces the bootstrap stub # (infrastructure/bootstrap-assets/kb-migration/) with the real handlers. @@ -317,6 +317,8 @@ jobs: run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-worker - name: Deploy kb-migration reconciler image run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-reconciler + - name: Deploy kb-migration document reconciler image + run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-document-reconciler - name: Deploy kb-migration ingestion consumer image run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-ingestion-consumer diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 457af1a0f..2321fe0a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,8 @@ jobs: run_backend: true run_frontend: true run_infra: true + # Pure-logic tests for tests/load. Deliberately only on the PR gate: the + # deploy workflows run test suites to protect a deploy, and the load + # suite has no bearing on one. + run_load_unit: true diff --git a/.github/workflows/nightly-deploy-pipeline.yml b/.github/workflows/nightly-deploy-pipeline.yml index ed9927421..f81b2b8c4 100644 --- a/.github/workflows/nightly-deploy-pipeline.yml +++ b/.github/workflows/nightly-deploy-pipeline.yml @@ -112,6 +112,15 @@ jobs: CDK_DOMAIN_NAME: "" CDK_CORS_ORIGINS: ${{ vars.CDK_CORS_ORIGINS }} CDK_VPC_CIDR: ${{ vars.CDK_VPC_CIDR }} + # Ephemeral nightly: smallest sizing that still exercises the multi-task + # paths. Pinned literally rather than read from vars.* so a production + # sizing bump never silently inflates the cost of a stack that exists for + # one test run. desiredCount stays at 2 so the shared BFF cookie key path + # is still covered. + CDK_APP_API_CPU: 512 + CDK_APP_API_MEMORY: 1024 + CDK_APP_API_DESIRED_COUNT: 2 + CDK_APP_API_MAX_CAPACITY: 4 CDK_ALB_SUBDOMAIN: ${{ inputs.alb-subdomain }} CDK_CERTIFICATE_ARN: ${{ vars.CDK_CERTIFICATE_ARN }} CDK_CLOUDFRONT_CERTIFICATE_ARN: "" diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index 5d80a24d5..b1dc8791a 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -9,6 +9,7 @@ on: - 'infrastructure/lib/platform-stack.ts' - 'infrastructure/lib/constructs/**' - 'infrastructure/lib/config.ts' + - 'infrastructure/cdk.context.json' - 'infrastructure/bin/infrastructure.ts' - 'infrastructure/bootstrap-assets/**' - 'scripts/platform/**' @@ -63,6 +64,25 @@ jobs: # teardown looked for 'dev' — matching nothing and reporting success. CDK_TAG_ENVIRONMENT: ${{ vars.CDK_TAG_ENVIRONMENT }} CDK_VPC_CIDR: ${{ vars.CDK_VPC_CIDR }} + # App API Fargate sizing. Per-environment because dev and prod have + # nothing in common here: prod must hold a resident burst (a 250-student + # section opening the app at once is ~5k requests in 30s, and a 60s + # scale-out cooldown cannot answer that — the headroom has to already be + # running), while dev only has to be correct. + # + # Leave a var unset to inherit the committed default in + # infrastructure/cdk.context.json — an unset var arrives as '' and + # parseIntEnv maps '' to undefined, so it falls through cleanly. + # + # cpu/memory must be a valid Fargate pair (1024 -> 2048..8192, + # 2048 -> 4096..16384). CFN rejects an invalid combination at deploy. + # Keep desiredCount >= 2 in every environment that matters: the shared + # BFF cookie key path (bff-cookie-key-construct.ts) only gets exercised + # with more than one task, and that is where a 'bad seal' 401 storm hid. + CDK_APP_API_CPU: ${{ vars.CDK_APP_API_CPU }} + CDK_APP_API_MEMORY: ${{ vars.CDK_APP_API_MEMORY }} + CDK_APP_API_DESIRED_COUNT: ${{ vars.CDK_APP_API_DESIRED_COUNT }} + CDK_APP_API_MAX_CAPACITY: ${{ vars.CDK_APP_API_MAX_CAPACITY }} CDK_ALB_SUBDOMAIN: ${{ vars.CDK_ALB_SUBDOMAIN }} CDK_CERTIFICATE_ARN: ${{ vars.CDK_CERTIFICATE_ARN }} # Shared us-east-1 CloudFront cert (wildcard covering {domain} + *.{domain}). @@ -178,27 +198,34 @@ jobs: # cdk.context.json stay inert. CDK_MCP_TOKEN_ENRICHMENT_ENABLED: ${{ vars.CDK_MCP_TOKEN_ENRICHMENT_ENABLED }} CDK_MCP_TOKEN_ENRICHMENT_CLAIMS: ${{ vars.CDK_MCP_TOKEN_ENRICHMENT_CLAIMS }} - # Managed knowledge bases (.kiro/specs/managed-kb-migration). THREE + # Managed knowledge bases (.kiro/specs/managed-kb-migration). FOUR # INDEPENDENT OPT-IN flags, all defaulting to OFF — the inverse of the # kill-switch flags above, and the difference matters here. An unset # GitHub Actions variable renders as an EMPTY STRING, not as absent, so # a `!== 'false'` reading of an unset variable would resolve to TRUE and # arm the feature on every fork. config.ts reads these with # parseBooleanEnv, which maps both unset and empty to undefined and falls - # through to `false` (Requirement 19.8). Leave all three unset to deploy + # through to `false` (Requirement 19.8). Leave all four unset to deploy # the managed backend without starting a fleet migration. # - # CDK_MANAGED_KB_NEW_DEFAULT new KBs are created managed - # CDK_MANAGED_KB_MIGRATION_ENABLED the background migrator runs at all - # CDK_MANAGED_KB_RECONCILER_ARMED the daily reconciler DELETES orphans - # rather than only reporting them + # CDK_MANAGED_KB_NEW_DEFAULT new KBs are created managed + # CDK_MANAGED_KB_MIGRATION_ENABLED the background migrator runs at all + # CDK_MANAGED_KB_RECONCILER_ARMED the daily reconciler DELETES orphans + # rather than only reporting them + # CDK_MANAGED_KB_DOC_RECONCILER_ARMED the nightly document reconciler + # CORRECTS stranded DOC# rows (marks + # retrievable-but-stranded docs complete, + # re-ingests missing ones) rather than + # only reporting them (task 16.5, §5.37) # - # reconcilerArmed is the inverted one: the Reconciler is deployed and - # running from day one but DISARMED, so its judgement can be reviewed - # against real data before it deletes anything (Requirements 14.7, 19.7). + # The two ARMED flags are the inverted ones: both reconcilers are deployed + # and running from day one but DISARMED, so their judgement can be reviewed + # against real data before they delete/correct anything (Requirements 14.7, + # 19.7). Report-only is read-only, so their schedules run regardless. CDK_MANAGED_KB_NEW_DEFAULT: ${{ vars.CDK_MANAGED_KB_NEW_DEFAULT }} CDK_MANAGED_KB_MIGRATION_ENABLED: ${{ vars.CDK_MANAGED_KB_MIGRATION_ENABLED }} CDK_MANAGED_KB_RECONCILER_ARMED: ${{ vars.CDK_MANAGED_KB_RECONCILER_ARMED }} + CDK_MANAGED_KB_DOC_RECONCILER_ARMED: ${{ vars.CDK_MANAGED_KB_DOC_RECONCILER_ARMED }} # Storage cost controls. Byte_Caps are in BYTES (Requirement 12.2), # defaulting to 100 MB standard / 1 GB elevated / 500 MB per knowledge # base; the retention window is in DAYS and must stay >= 30 @@ -235,6 +262,11 @@ jobs: CDK_OBSERVABILITY_PROMPT_CACHE_AVOIDABLE_MISS_THRESHOLD: ${{ vars.CDK_OBSERVABILITY_PROMPT_CACHE_AVOIDABLE_MISS_THRESHOLD }} CDK_OBSERVABILITY_PROMPT_CACHE_WASTED_USD_THRESHOLD: ${{ vars.CDK_OBSERVABILITY_PROMPT_CACHE_WASTED_USD_THRESHOLD }} CDK_OBSERVABILITY_PROMPT_CACHE_SESSION_WASTED_USD_THRESHOLD: ${{ vars.CDK_OBSERVABILITY_PROMPT_CACHE_SESSION_WASTED_USD_THRESHOLD }} + CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT: ${{ vars.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT }} + # JSON object: Bedrock ModelId -> that model's TPM quota. Unset means no + # per-model quota alarms. Read the live values with + # aws service-quotas list-service-quotas --service-code bedrock + CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS: ${{ vars.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS }} # Secrets AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ee059e6b..12c16a8c6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,11 @@ on: required: false default: false type: boolean + run_load_unit: + description: 'Run load-test unit suite (pure logic; no AWS, no load generated)' + required: false + default: false + type: boolean permissions: contents: read @@ -99,3 +104,44 @@ jobs: - name: Run tests working-directory: infrastructure run: npx jest --maxWorkers=2 + + # Unit tests for tests/load — the SSE parser, the Cognito login-form parser, + # and the config guards. Pure logic: no AWS credentials, no network, and no + # load is generated, so this is safe on every PR. + # + # Why it is a gate at all: the locustfiles encode the chat path's request + # payload and SSE event names. Renaming an event in + # agents/main_agent/streaming/ or changing the /chat/stream contract should + # fail here rather than silently produce a load test that reports every turn + # as never finishing. Running the actual load test needs provisioned Cognito + # users and spends real Bedrock tokens — that is never done in CI. + test-load-unit: + if: ${{ inputs.run_load_unit }} + name: Test load suite (pytest) + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: '0.7.12' + enable-cache: true + cache-dependency-glob: 'tests/load/uv.lock' + - name: Sync deps + working-directory: tests/load + run: uv sync + - name: Run pytest + working-directory: tests/load + run: uv run pytest tests/ -v + - name: Verify locustfiles import + working-directory: tests/load + # `--list` loads each locustfile and prints its user classes without + # starting a run. Catches an import error or a scenario accidentally + # made abstract, which the unit tests would not. + run: | + uv run locust -f locustfile.py --list + uv run locust -f locustfile_readonly.py --list diff --git a/.kiro/specs/managed-kb-migration/HANDOFF.md b/.kiro/specs/managed-kb-migration/HANDOFF.md index bfb513e2a..e1f2ab8ca 100644 --- a/.kiro/specs/managed-kb-migration/HANDOFF.md +++ b/.kiro/specs/managed-kb-migration/HANDOFF.md @@ -20,13 +20,16 @@ Four things invalidate earlier versions of this document: deployed clean. They are §5 items 25–41 and they are the most useful part of this document. Three clusters: 32–36 trace to the two engines never being made exclusive (PR #900); 37–39 to the ingestion consumer never actually knowing when - a document was ready (PRs #901, #908); and **40–41 are about answer quality and - are both still OPEN** — the managed backend currently gives a *worse* answer than - legacy on a question it retrieves *better*. Start there. §5.33 is also open. + a document was ready (PRs #901, #908); both answer-quality items are now RESOLVED + — **40 (the context cap) via PR #997 (task 16.1) and 41 (diagram answer quality) + via task 16.2**. §5.41 is *understood and closed as a product/training matter, not + a code fix*: the managed backend gives a worse per-column answer than legacy on + structured diagrams, but on a self-service platform the mitigation is user + guidance, not engineering (see §5.41). §5.33 is also RESOLVED (PR #998, task 16.3). 3. **The `document_id` "known unknown" was a false alarm** and is now resolved with measurements — see §6. An earlier revision listed it as the top open risk. The probe was reading facade keys that have never existed. Two genuine findings came - out of checking it (§5.32, §5.33), both still open. + out of checking it (§5.32, §5.33), since resolved (§5.32 in PR #900, §5.33 in PR #998). 4. **Iterate locally, but do not trust it for IAM.** `scripts/local-dev/run-kb-migration.py` drives the whole state machine in-process against dev with your SSO credentials. Three of the @@ -688,7 +691,7 @@ the managed engine at all. Every symptom below follows from that. misleading. The bootstrap copy is a 33-line no-op placeholder that indexes nothing and needs no gate. -33. ⚠️ **STILL OPEN. The document-status filter has one fail-*open* line in an +33. ✅ **RESOLVED (PR #998, task 16.3). The document-status filter had one fail-*open* line in an otherwise fail-closed function.** `_filter_vectors_by_document_status` opens with `if not doc_ids: return vectors` — so a batch of chunks carrying no `document_id` at all bypasses the DynamoDB check entirely and is served @@ -879,7 +882,7 @@ These two are different in kind from everything above. Nothing is broken, no error is raised, every test passes — and the managed backend gives a **worse answer than legacy** on a question it retrieves *better*. Both are open. -40. ⚠️ **OPEN, and the most consequential item in this document. The +40. ✅ **RESOLVED (PR #997, task 16.1). Was the most consequential open item. The 2,000-character context cap silently reduces `top_k=5` to `top_k=1` on the managed backend.** Bedrock's chunks are roughly 3× larger than Docling's, and `MAX_CONTEXT_CHARS` was sized for Docling's. Measured on one query: @@ -924,7 +927,8 @@ answer than legacy** on a question it retrieves *better*. Both are open. (0.0561 legacy versus 0.4988 managed). I measured the retriever and never checked the answer. Score separation is not answer quality. -41. ⚠️ **OPEN. Column-structured diagrams yield confidently wrong answers.** The +41. ✅ **RESOLVED (task 16.2) — understood; closed as a product/training matter, no + code fix. Column-structured diagrams yield confidently wrong answers.** The capability in §5.35 is real — an image-only flowchart that legacy cannot ingest at all becomes retrievable, and Bedrock's vision model genuinely decodes it. But asked "what should they take semester 4?" from a 3.5-year curriculum @@ -943,10 +947,32 @@ answer than legacy** on a question it retrieves *better*. Both are open. boundaries — the digit after "Semester" fell into the next bucket. If it takes that to check, a chunk of prose was never going to carry it. - **Guidance until this is understood:** image extraction is worth demonstrating - as *retrievable where it was previously impossible*, not as a source of precise - tabular answers. Do not put a per-column question from a diagram in front of an - audience. + **Re-measured 2026-09-08** on the live diagram corpus (`ast-1a90784a7f18`, + `4-yr-flowchart-v2026.pdf`) with both read-only harnesses. Legacy returned **0 + chunks on every query** — the image-only PDF is unusable there — while managed + returned 5, so the capability gain is one-directional. At the chunk level the + failure is visible directly: the vision narrative splits into a header-only + chunk (`"Semester 1 Semester 2 Semester 3 Semester 4 …"`, no courses), course + chunks with no semester tag (`"ME 215 …"`, `"CHEM 111 … 1 credit"`), and a few + single-semester narratives. For "semester 4?" the top-ranked chunk is about + *Semester 3*. **The task-16.1 cap fix does not rescue this:** at both 2,000 and + 8,000 chars the answer reported 14 credits (chart: 19) and kept the mis-columned + `ENGR 220`. The existence question ("does it include a capstone?") was correct + at both caps. More context cannot restore a coordinate that was never captured. + + **Why we are not fixing it in code.** A text sidecar *would* work — managed + ingests whatever lands in our S3 data source, and a text table keeps its + row/column structure because they are literal characters, not pixels — but this + is **not a single-tenant tool we administer**. Users create their own agents + backed by these knowledge bases, and a regular user has no way to know a + flowchart needs converting to a text table for good answers. So the honest + mitigation is **user training and guidance** on which content and which agent + types work best, not an engineering change. + + **Standing guidance:** demo and describe image extraction as *retrievable where + it was previously impossible*, never as a source of precise tabular answers. Do + not put a per-column question from a diagram in front of an audience, and do not + promise precise per-semester / per-column results from image-only documents. --- @@ -964,10 +990,10 @@ answer than legacy** on a question it retrieves *better*. Both are open. | Group | Notes | |---|---| -| **§5.40** the 2,000-char cap — START HERE | Managed's chunks are ~3× Docling's, so only ONE reaches the model and reranking's other four results are discarded. It produced a materially wrong answer (a required course described as an elective). Needs measurement, not a constant bump: the cap is a parity control (§9, §13.5). Options are managed-only asymmetry, or re-running §13.6 on a corpus where section context sits outside the top chunk | -| **§5.41** diagram answers | Column-structured diagrams give confident wrong answers because chunks carry no coordinates. Understand the shape before promising anything about tabular image content | -| **§5.33** the one fail-open line | The only finding from 2026-08-31 still open. `if not doc_ids: return vectors` in `_filter_vectors_by_document_status`. Make it fail closed with `METRIC_STATUS_FILTER_FAIL_CLOSED` like every other unprovable path in that function, and pin it with a test that mutation-fails. Lower stakes now that §5.36 removes deleted content from the managed engine, but still the one silent-serving path left | -| **engine visibility** | Nothing logs *which* engine served a query — the resolver only logs on failure — so "is the new one actually working?" can only be answered from the KB record. One INFO line in the facade, plus a `Managed`/`Classic` badge in the knowledge base section, both unbuilt. Wanted before a wide rollout, because this feature's whole risk profile is silent regressions | +| ~~**§5.40** the 2,000-char cap~~ ✅ DONE (PR #997) | Engine-aware cap: managed **8,000**, legacy 2,000 (`rag_service.resolve_context_cap`, keyed on `resolve_engine_for`). Requirement 3.2 amended; validated end-to-end on the KINES advising corpus in dev; mutation-tested | +| ~~**§5.41** diagram answers~~ ✅ DONE (task 16.2) | Understood: the vision model flattens the 2-D layout, chunks carry no column coordinates, and the 16.1 cap fix does not rescue it (re-measured 2026-09-08: 14 credits vs the chart's 19 at both caps, mis-columned `ENGR 220` persists). Closed as a **product/training matter, not code** — this is a self-service platform, and users won't know to convert a diagram to text. Guidance: demo as *retrievable where previously impossible*, never precise per-column answers | +| ~~**§5.33** the one fail-open line~~ ✅ DONE (PR #998) | `if not doc_ids: return vectors` now returns `[]` + `METRIC_STATUS_FILTER_FAIL_CLOSED` when a non-empty batch carries no `document_id`; an empty input stays an empty result with no metric. Guard `test_filter_fails_closed_when_no_chunk_carries_a_document_id`, mutation-tested | +| ~~**engine visibility**~~ ✅ DONE (task 16.4) | The facade now logs one INFO line per query naming the served engine (`engine=managed (Managed)` / `engine=s3vectors (Classic)`), read from the same KB_Record `resolve_backend` uses. The knowledge base section carries a `Managed`/`Classic` badge, fed by a new `engine` field on `UpgradeStatusResponse` (defaults `classic`), and its per-document status vocabulary is engine-aware — `uploading → processing → ready` for managed, `chunking`/`embedding` kept for legacy. Mutation-tested. Branch `feat/kb-engine-visibility` | | **14.4** one-click document retry | Req 21.2. Ingestion is S3-event-triggered and there is no reprocess endpoint, so this needs new backend against a live pipeline. The card currently directs the user to re-upload, which works today. Close it by building the endpoint **or** by amending Req 21.2 to accept re-upload | | **14.5** admin surface | not started. Filter by engine, stored bytes, document counts, bulk migrate, per-KB retry | | **15.1** packaged-SDK probe | the *static* half is done and passing (`boto3==1.43.68` carries `MANAGED`, the embedding members, `FLOAT32`, all four document ops, no `AWS_DATA_PATH`). The live half has now effectively been done by hand — a real create → ingest → retrieve → promote succeeded in dev | diff --git a/.kiro/specs/managed-kb-migration/requirements.md b/.kiro/specs/managed-kb-migration/requirements.md index 3bcaa3b7d..a8f554772 100644 --- a/.kiro/specs/managed-kb-migration/requirements.md +++ b/.kiro/specs/managed-kb-migration/requirements.md @@ -69,11 +69,15 @@ The following are explicitly **not** in scope, each for a stated reason: > shape as Requirement 8.5, which was also validated under conditions that > excluded the real failure. > - > This exclusion stands for now, because the cap is a parity control and moving - > it forfeits attributability (§9, §13.5). It is no longer justified by "no - > correctness change", which is false. Resolving it requires either accepting a - > managed-only cap and declaring the asymmetry, or re-running §13.6 on a corpus - > where section context sits outside the top chunk. See HANDOFF.md §5.40. + > **RESOLVED 2026-09-04:** the cap is now **engine-aware** — legacy stays 2,000, + > managed becomes 8,000 (`rag_service.resolve_context_cap`), which restores parity + > in chunks-reaching-the-model rather than characters and is sized from §13.6 + > itself (8,000 = all five managed chunks fit, ~966 extra input tokens/turn). This + > does **not** forfeit attributability — the single-character cap had *already* + > broken it (~4 legacy chunks vs ~1 managed). Confirmed on the KINES advising + > corpus in dev (1 of 4 emphasis areas answered from the documents at 2,000; all + > four at 8,000). Raising the cap on the *legacy* path remains out of scope. See + > Requirement 3.2 (amended) and HANDOFF.md §5.40. - **0..N agent-to-KB bindings (F4).** §10.6 requires that the engine swap and the binding-cardinality change not be coupled, because a joint failure is unattributable. This spec lands the `KnowledgeBase` entity record while @@ -199,15 +203,26 @@ the upgrade. #### Acceptance Criteria 1. THE system SHALL request `top_k = 5` on both backends. -2. THE system SHALL apply a context cap of **2,000 characters** on both backends, - unchanged from today's `max_context_length` default. - - > ⚠️ Identical characters is **not** identical behaviour. Bedrock's chunks are - > ~3× Docling's, so this same number admits ~4 legacy chunks and ~1 managed - > chunk, making `top_k = 5` above effectively `top_k = 1` on the managed path. - > This is parity on the constant, not on the effect. Measured, with a wrong - > answer to show for it — see the amendment in the out-of-scope list above and - > HANDOFF.md §5.40 before treating this requirement as satisfied. +2. THE system SHALL apply an **engine-aware** context cap — **2,000 characters** + on the Legacy_Backend and **8,000 characters** on the Managed_Backend — + resolved by `rag_service.resolve_context_cap` from the knowledge base's + Retrieval_Engine, the same value the backend resolver keys on, so the cap and + the served engine can never disagree. + + > ⚠️ **Amended 2026-09-04 by measurement (was: 2,000 on both backends).** + > Identical characters is **not** identical behaviour. Bedrock's chunks are ~3× + > Docling's, so a single 2,000 cap admitted ~4 legacy chunks but only ~1 managed + > chunk — making `top_k = 5` (3.1) effectively `top_k = 1` on the managed path, + > and it produced materially wrong answers (a Major-Core course described as an + > elective; on the KINES advising corpus, only 1 of 4 emphasis areas described + > from the documents with the rest guessed from outside knowledge the assistant + > was told not to use). 8,000 is the evaluation's own §13.6 sizing: the point at + > which all five managed chunks fit, ~966 extra input tokens/turn. The + > managed-only asymmetry is **deliberate and restores parity in the unit that + > matters** — chunks reaching the model, not characters. §13.6's "no correctness + > change 2,000→20,000" covered single-fact lookups only and flagged multi-chunk + > synthesis — the case that broke — as untested. See HANDOFF.md §5.40. Guarded by + > `tests/shared/test_kb_backend_parity.py` (mutation-tested). 3. THE system SHALL retain the Doc_Status_Filter on **both** backends during parity, even though Managed_Backend makes it redundant. 4. THE system SHALL build citations from the same `context_chunks` structure on diff --git a/.kiro/specs/managed-kb-migration/tasks.md b/.kiro/specs/managed-kb-migration/tasks.md index 2b1cea33b..d058bed74 100644 --- a/.kiro/specs/managed-kb-migration/tasks.md +++ b/.kiro/specs/managed-kb-migration/tasks.md @@ -766,7 +766,16 @@ All three flags — managed-default, migration, and reconciler arming — ship * - [ ] 16. Post-implementation findings (opened by running it — see `HANDOFF.md` §5) - - [ ] 16.1 Resolve the 2,000-character context cap on the managed path + - [x] 16.1 Resolve the 2,000-character context cap on the managed path + - **RESOLVED 2026-09-04 (Option A: engine-aware cap).** `rag_service.resolve_context_cap` + returns 2,000 for legacy, 8,000 for managed, keyed on the same `resolve_engine_for` + the backend resolver uses. Both call sites (`inference_api/chat/routes.py`, + `app_api/assistants/routes.py`) pass it. Requirement 3.2 amended; out-of-scope + note updated. Guard in `tests/shared/test_kb_backend_parity.py`, mutation-tested + (dropping the managed branch fails two named tests). Measured end-to-end on a + prod-derived KINES advising corpus re-created in dev (`ast-1d51df6ea532`): at + 2,000 the model described 1 of 4 emphasis areas from the docs and guessed the + rest; at 8,000 all four came from the documents. Branch `fix/kb-managed-context-cap`. - **The most consequential open item.** Bedrock's chunks are ~3× Docling's, so only ~1 chunk clears the cap and four of reranking's five results never reach the model. Measured: legacy 388/130/1035/106 chars (4 fit) vs managed @@ -782,7 +791,7 @@ All three flags — managed-default, migration, and reconciler arming — ship * carries the contradicting measurement). - _HANDOFF §5.40 · Requirements: 3.1, 3.2_ - - [ ] 16.2 Understand diagram answer quality before promising anything + - [x] 16.2 Understand diagram answer quality before promising anything - Column-structured diagrams yield confident wrong answers: a curriculum flowchart reported 11 credits where the chart says 19, invented a course from an adjacent column, and missed four others. Correctness depends on which @@ -790,15 +799,57 @@ All three flags — managed-default, migration, and reconciler arming — ship * - Image extraction genuinely works (§5.35) — an image-only PDF that legacy cannot ingest at all becomes retrievable. The capability is real; precise tabular answers from it are not established. + - **RESOLVED (2026-09-08).** Re-measured on the live diagram corpus + (`ast-1a90784a7f18`, `4-yr-flowchart-v2026.pdf`) with both read-only harnesses: + legacy returned 0 chunks on every query (image-only PDF unusable on legacy), + managed returned 5. The vision narrative loses the column binding — the + header-only chunk carries no courses, the course chunks carry no semester — so + a per-column question ("semester 4?") is answered confidently wrong (14 credits + vs the chart's 19; the mis-columned `ENGR 220` persists). Raising the cap + 2,000→8,000 (task 16.1) did NOT fix it. Existence questions ("does it include a + capstone?") are correct at both caps. + - **Decision: no code fix.** A text sidecar would work (managed ingests whatever + lands in our S3 data source, and a text table keeps its structure), but this is + a self-service platform — users create their own agents and would not know to + convert a document. The mitigation is user training/guidance on which content + and agent types work best, not engineering. Demo image extraction as + *retrievable where previously impossible*, never as precise per-column answers. - _HANDOFF §5.41_ - - [ ] 16.3 Make the document-status filter fail closed on its one open path + - [x] 16.3 Make the document-status filter fail closed on its one open path + - **RESOLVED (§5.33).** `_filter_vectors_by_document_status`'s `if not doc_ids: + return vectors` now fails closed: a non-empty batch where no chunk carries a + `document_id` returns `[]` and emits `METRIC_STATUS_FILTER_FAIL_CLOSED`, like + every other unprovable path. An empty input stays an empty result with no + metric (an ordinary "no match", not a degradation). Guard + `test_filter_fails_closed_when_no_chunk_carries_a_document_id` in + `tests/shared/test_search_filtering.py`, mutation-tested (reverting to + `return vectors` fails it). Branch `fix/kb-status-filter-fail-closed`. - `_filter_vectors_by_document_status` opens with `if not doc_ids: return vectors`. Every other unprovable path in that function returns `[]` and emits `METRIC_STATUS_FILTER_FAIL_CLOSED`. Predates this feature; not firing today. - _HANDOFF §5.33 · Requirements: 5.1, 5.2_ - - [ ] 16.4 Give the UI one vocabulary and show which engine served a query + - [x] 16.4 Give the UI one vocabulary and show which engine served a query + - **RESOLVED (task 16.4).** Two surfaces, one source (the server-derived + engine on the upgrade status). (1) **Status vocabulary** is now + engine-aware: `KnowledgeBaseSectionComponent.statusLabel` reads + `uploading → processing → ready` (+ `failed`) for a managed knowledge base + and keeps `uploading/chunking/embedding/complete/failed` for legacy + assistants, which still emit them. Fixes the card showing `Uploading` for + the whole managed indexing wait (managed writes only `uploading` then + `complete`, PR #900). (2) **Engine visibility**: `rag_service`'s facade + logs exactly one INFO line per query naming the served engine + (`engine=managed (Managed)` / `engine=s3vectors (Classic)`), read from the + same KB_Record `resolve_backend` uses so it cannot disagree; and a + `Managed`/`Classic` badge renders beside the document list, fed by a new + `engine` field on `UpgradeStatusResponse` (defaults `classic`, + absence-means-legacy). Mutation-tested guards in + `tests/shared/test_kb_backend_parity.py` (log line + content), + `tests/routes/test_kb_upgrade.py::TestEngineBadge`, + `kb-upgrade.service.spec.ts` and + `knowledge-base-section.component.spec.ts`; no user-facing string says + "vector" (Req 23.6). Branch `feat/kb-engine-visibility`. - Document status is now written only by the owning engine (PR #900), so the legacy `chunking`/`embedding` words never appear for a promoted knowledge base — but nothing replaced them, so the card shows `Uploading` for the whole @@ -819,4 +870,27 @@ All three flags — managed-default, migration, and reconciler arming — ship * both needed manual repair. - Overlaps task 14.4 (one-click retry) and the report-only reconciler, which already knows how to join Bedrock's view against ours. + - **Backend built (report-only), branch `feat/kb-deadletter-reconcile`.** + `kb_migration/document_reconciler.py` is the missing second writer of `DOC#` + status: once a day it finds rows stuck non-terminal (`uploading`/`chunking`/ + `embedding`) past a 60-minute grace gate, asks Bedrock the ground truth per + document, and — the §5.37 case — drives a stranded-but-**retrievable** document + to `complete`. It reuses the consumer's own probes (`document_status`, the + `equals`-on-`document_id` retrievability search, its status-set constants, and + `set_document_terminal`) so §5.37/§5.38/§5.39 live in one place, not four. A + `FAILED` document is driven to `failed`; a `NOT_FOUND` one (dead-lettered before + ingest) is **re-ingested** from the S3 bytes — the scheduled form of 14.4's + one-click retry. Modelled on `reconciler.py`: **ships disarmed** + (`MANAGED_KB_DOC_RECONCILER_ARMED`, empty ⇒ off), per-run action limit applies + in both modes so the report is trustworthy, grace gate is a pure function of the + row's own `updatedAt` and fails closed. `terminal`/`deleting` rows are never + candidates (a soft-deleted doc must not be resurrected). Guards in + `tests/lambdas/test_kb_document_reconciler.py`, mutation-verified (neutering the + retrievability gate fails `test_indexed_but_not_retrievable_is_left_short_of_complete`; + widening `NON_TERMINAL_STATUSES` fails `test_terminal_and_deleting_rows_are_never_candidates`). + - **Remaining (deploy-gated follow-up, not in this PR):** wire the reconciler's + own Lambda + EventBridge schedule + IAM in `kb-migration-construct.ts`, then set + and eventually flip `MANAGED_KB_DOC_RECONCILER_ARMED`. The flag is exempted in + `test_kb_migration_env_contract.py`'s `OPTIONAL_OVERRIDES` until that wiring + lands. - _HANDOFF §5.37 · Requirements: 21.2_ diff --git a/.kiro/steering/observability.md b/.kiro/steering/observability.md index 6205c7819..8a133b129 100644 --- a/.kiro/steering/observability.md +++ b/.kiro/steering/observability.md @@ -256,7 +256,7 @@ cannot be quietly reversed. | `agentcore-high-error-rate` | `UserErrors` — our requests are malformed | Recent inference-api deploy? Check payload shape and IAM. | | `agentcore-throttles` | At the TPS or session quota | Request a quota increase. Will not self-resolve. | | `agentcore-high-latency` | p99 above 120s | Genuinely hung, not merely slow — 24s is a normal maximum here. | -| `bedrock-tpm-quota-usage` | **Leading** indicator | Request a quota increase *now*, before throttling starts. | +| `bedrock-tpm-quota-usage-` | **Leading** indicator | First confirm the configured quota still matches Service Quotas — it is set by hand and nothing checks it. If current, request an increase *now*, before throttling starts. | | `bedrock-invocation-throttles` | At a model's TPM/RPM quota | Users see chats that never respond. Quota increase. | | `agentcore-memory-*` | Memory hot path failing | Users experience an agent that has forgotten the conversation. | | `agentcore-gateway-*` | MCP calls failing at the gateway | Agents lose tool access. Check gateway targets. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 58522f0a7..2d1a49ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,74 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.20.0] - 2026-09-09 + +Two new capabilities and one measurement that changes how the platform should be scaled. `browse_web` drives a real Chrome browser in the AgentCore Browser sandbox with a live view the user can watch, and fine-tuning gains a fourth task type — **generative VLMs**, LoRA-adapted over a 4-bit base, so a model can *write* an answer about an image instead of only classifying it. Alongside them, a load-testing harness establishes that the campus-scale ceiling is the **Bedrock TPM quota, not compute**: a representative production turn costs ~26,700 quota-counted tokens against the ~1,920 a naive load profile assumes, so a single 300-student class exceeds the default 6,000,000 TPM quota. MCP Apps get the end of a three-part chain that made every button in an embedded App fail silently after a reload, including the discovery that **AgentCore Runtime rewrites any non-2xx container response to a generic 424 and discards the body**. A managed-KB dead-letter reconciler closes the case where a document is indexed and retrievable in Bedrock but can never be cited. **Requires a CDK deploy**, and three defaults change for a fork that configures nothing — app-api Fargate sizing, the Bedrock quota alarm, and a new nightly Lambda. + +### 🚀 Added + +- **`browse_web`** — nine-action browser tool (`navigate`, `extract_text`, `extract_links`, `click`, `type`, `evaluate`, `screenshot`, `live_view`, `close`) driving Chrome in the AgentCore Browser sandbox over a hand-rolled CDP client on the SigV4-signed WebSocket. No new dependency: `websockets` was already in the image. Seeded **disabled by default**; the session id persists on `agent.state` so a rebuilt agent reconnects instead of starting a second billed browser. **Requires a Seed Bootstrap Data run to appear in the catalog** (#1010) +- **Generative VLM fine-tuning** — new `image-text-to-text` task type training a LoRA adapter over a 4-bit NF4-quantised base, with five catalog models (SmolVLM-Instruct 2.2B, LLaVA-1.5-7B, LLaVA-1.6-Mistral-7B, Qwen2.5-VL-7B-Instruct, LLaVA-1.6-34B). Loss is masked to the response span; the artifact is an adapter, not a model. Pre-flight now accepts any Hub checkpoint tagged `image-text-to-text` (#1014) +- **Managed-KB dead-letter document reconciler** — nightly Lambda (`cron(0 9 * * ? *)`) that finds documents left non-terminal by an exhausted Lambda async retry, re-probes Bedrock, and marks them `complete` (only after a filtered retrievability check), `failed`, or re-ingests from S3. **Report-only unless `CDK_MANAGED_KB_DOC_RECONCILER_ARMED` is explicitly truthy**; armed runs are capped at 25 corrections per night. Five new `{prefix}/ManagedKb` metrics (#1007, #1008, #1018) +- **Load-testing harness** — Locust suite at `tests/load/` with real Cognito Hosted-UI login and client-side SSE instrumentation (time-to-first-token and full-turn metrics, since ALB `TargetResponseTime` cannot complete until a stream closes), a classroom-burst load shape, a per-user credential pool that refuses to start under-provisioned, `scripts/load-test/` Cognito provisioning, and `watch-tpm.sh` for live Bedrock quota headroom (#1020) +- **Run-by-this-App actions chip** on the MCP App frame — app-initiated tool calls now surface on the frame that ran them as `3 actions · 1 failed`, with successes collapsed into one summary line, replacing the wall of standalone "RAN BY APP" cards (#1002) +- **`Managed` / `Classic` engine badge** on the Uploaded Documents panel, plus one INFO line per retrieval naming the engine (#1006) + +### ✨ Improved + +- **MCP App frames survive leaving and returning to a conversation.** The UI-resource registry is keyed per conversation and no longer reset on route change, so an App renders as an App on the second visit instead of degrading to a plain tool card until a hard refresh. Apps produced by a background stream are also retained (#1000) +- **An MCP server shipping a new App version now reaches existing conversations.** A stored `UIRES#` row is revalidated against the server after an app-initiated tool call, so the platform stops being the durable store of record for a resource the server owns. Refreshed shells land on the next page load (#1003) +- **An App that saves state on teardown now gets to.** `McpAppBridge.dispose()` waits up to 1500ms for the View's ack instead of removing the listener in the same tick, and a new `McpAppTeardownService` fires teardown on conversation change while the iframe is still alive (#1003) +- **Managed-KB document status reads `Processing` → `Ready`** instead of showing the literal "Uploading" for the entire indexing wait; legacy KBs keep their finer-grained vocabulary (#1006) +- **Per-environment app-api Fargate sizing** via `CDK_APP_API_CPU`, `CDK_APP_API_MEMORY`, `CDK_APP_API_DESIRED_COUNT`, `CDK_APP_API_MAX_CAPACITY`. The loader now reads the **flat** dotted context key CDK actually sets, so a `--context appApi.cpu=…` override is no longer accepted and discarded (#1020) + +### 🐛 Fixed + +- **Every button in an embedded MCP App failed silently after a page reload**, showing the MCP server's own "isn't connected yet" text. An app-initiated `tools/call` bypasses the agent's tool loop, so Strands never raises `BeforeToolCallEvent` and `OAuthConsentHook` — the only thing that warms `oauth_token_cache` — never ran. The request went out with no `Authorization` header at all, and because no 401 came back the existing 401-triggered recovery never fired either. The token is now resolved before dispatch, and a consent requirement raises 409 (never 401, which the SPA treats as an expired BFF session) (#1001) +- **A consent-required App tool call rendered "Received error (424) from runtime. Please check your CloudWatch logs."** AgentCore Runtime rewrites **any** non-2xx container response to a generic 424 and discards the body, so both the status and the message were destroyed before app-api saw them. Errors now cross the boundary as HTTP 200 plus an `appToolError` envelope, which app-api unwraps back to the real status. An unlisted or malformed status collapses to 502 rather than letting upstream pick, and an enveloped error persists no provenance card (#1009) +- **The restored consent message still didn't reach the toast.** app-api returned `{"error": ""}`, but `ErrorService` looks for a top-level string `detail`, an *object*-valued `error` carrying `.detail`/`.message`, or a top-level string `message` — a string-valued `error` matches none of the three. The body now carries `detail` alongside `error`; AgentCore's own 424 body had been rendering for exactly the reason the useful text was not (#1013) +- **A managed KB gave wrong answers from correctly-retrieved documents** — a Major-Core course reported as an elective, three of four emphasis areas invented. `MAX_CONTEXT_CHARS = 2000` was shared "for parity", but Bedrock's chunks are roughly 3x larger than Docling's and the cap truncates per accumulated chunk, so a requested `top_k=5` silently became **top_k=1 at the model** and the dropped neighbours carried the section header. Managed KBs now resolve an 8,000-character cap via `resolve_context_cap`; legacy is unchanged (#997) +- **Retrieval could serve chunks whose parent document status was never verified, including deleted content.** `_filter_vectors_by_document_status` opened with `if not doc_ids: return vectors`, so a non-empty batch in which every chunk resolved to an empty `document_id` bypassed the DynamoDB status check entirely rather than failing closed like every other unprovable branch. Now returns `[]` and emits `KbStatusFilterFailClosed` (#998) +- **The `bedrock-tpm-quota-usage` alarm was effectively a "is anyone using the product?" detector** — above threshold for 195 of 197 datapoints over 24 hours and 205 state transitions in six days, burying every genuine alert on the SNS topic. It compared the absolute-token-count metric `EstimatedTPMQuotaUsage` against a literal `80` as though it were a percentage, and read the account-wide roll-up, which has no single denominator because Bedrock quotas are per model and per inference profile. Replaced by one opt-in alarm per configured model (#1016) +- **Voice Mode would have silently switched off** on the `strands-agents` 1.55.0 upgrade. The Nova Sonic provider moved from `strands.experimental.bidi.models.nova_sonic.BidiNovaSonicModel` to `strands.experimental.bidi.models.bedrock.BedrockNovaSonicModel` — a rename this repo swallows, because `voice_agent.py` imports the provider inside `except ImportError: BIDI_AVAILABLE = False`. Not mentioned in the upstream release notes (#1012) + +### 🔒 Security + +- **Sandbox escape in the Calculator tool's expression allowlist** (`strands-agents-tools` 0.8.6 → 0.8.8). A string literal is normally rejected because a string reaching `sympify` gets re-parsed outside the restricted namespace, but the allowlist trusted one as a positional argument to `Symbol`/`symbols`/`Rational`/`Integer`/`Float` — and checked **only the positional argument, ignoring keyword arguments**. `symbols('...', cls=N)` therefore rerouted the string through `sympify` anyway. Upstream now trusts a string positional only when every keyword is a boolean assumption flag, and treats `**kwargs` unpacking as untrusted. The tool is registered on the default agent and seeded `enabledByDefault: True`, so the exposure was live rather than theoretical. No CVE or GHSA identifier was issued (#1011) + +### ⚠️ Changed + +- **Breaking (cost):** default app-api Fargate sizing rises from 512 CPU / 1024 MiB to **1024 CPU / 2048 MiB** per task, at 2 tasks. A fork that sets nothing gets roughly double the app-api compute bill on the next platform deploy; set `CDK_APP_API_CPU=512` and `CDK_APP_API_MEMORY=1024` to keep the old sizing. cpu/memory must remain a valid Fargate pair or the deploy fails rather than falling back (#1020) +- **The account-wide `{prefix}-bedrock-tpm-quota-usage` alarm is deleted** and replaced by `{prefix}-bedrock-tpm-quota-usage-`, one per entry in the new `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS` map. **A fork that configures nothing gets zero quota alarms** — the noise stops, and the leading indicator for quota pressure is opt-in. `bedrock-invocation-throttles` remains as the no-configuration backstop (#1016) +- **`McpAppBridge.dispose()` returns `Promise`** and takes an optional grace-period argument, and keeps serving inbound messages for up to 1500ms after teardown is requested. Relevant only to forks that have extended this class (#1003) +- Fine-tuning script packaging moved from a single `scripts/sourcedir.tar.gz` to per-family `scripts/sourcedir-{text,vision,vlm}.tar.gz`, because `bitsandbytes==0.50.2` requires torch ≥ 2.4 and the text DLC is torch 2.1 — one shared requirements file would have broken dependency installation for every existing text job. The old object is orphaned, not read, and can be deleted by hand (#1014) + +### 🏗️ Infrastructure + +- New `KbDocumentReconcilerLambda` (ARM64, 15 min, 512 MB) sharing the existing byte-stable kb-migration image asset, its role and log group, an EventBridge rule `KbDocumentReconcilerSchedule` at `cron(0 9 * * ? *)`, and SSM parameter `/{prefix}/kb-migration/document-reconciler-function-name`. IAM is `assistantsTable` read/write, `documentsBucket` read-only, plus the `ManagedKbDirectIngestion` and `ManagedKbRetrieve` grants — deliberately **not** provisioning rights and **not** `iam:PassRole`. No new table and **no GSI operation on any existing table**. The construct is instantiated unconditionally, so **the Lambda and the enabled nightly rule are created regardless of every `managedKb` flag** (#1008) +- Per-model Bedrock quota alarms replace one account-wide alarm; net resource change is **−1** for a fork that configures nothing. New `parseModelQuotaMapEnv` accepts a quote-free `modelId=quota,…` form, which is the recommended one because `deploy.sh` runs `eval npx cdk synth` and `eval` strips quotes (#1016) +- `infrastructure/cdk.context.json` added to the `platform.yml` push-paths filter — a sizing edit there previously triggered no deploy (#1020) + +### 🔧 CI/CD + +- New `CDK_MANAGED_KB_DOC_RECONCILER_ARMED` forwarded from `vars.*` in the platform deploy job's **job-level** `env:`. Without it the flag was threaded through `load-env.sh` and `config.ts` but could never be set from a GitHub Variable — the silent-accept-then-ignore failure the observability guidance warns about (#1018) +- New job-level `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT` and `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS` (#1016) +- New job-level `CDK_APP_API_CPU`, `CDK_APP_API_MEMORY`, `CDK_APP_API_DESIRED_COUNT`, `CDK_APP_API_MAX_CAPACITY`; `nightly-deploy-pipeline.yml` pins 512/1024/2/4 literally so a production sizing bump never inflates an ephemeral nightly stack (#1020) +- New `Test load suite (pytest)` job on every PR into `develop`/`main`, plus `locust --list` on both locustfiles — the load suite encodes the `/chat/stream` payload and SSE event names, so renaming a stream event fails CI instead of silently producing a load test that reports every turn as never finishing (#1020) +- `deploy-image-lambda-one.sh` and `backend.yml` gain a `kb-migration-document-reconciler` case (#1008) +- `shellcheck` 0.9.0 and `actionlint` 1.7.12 added to the dev container and its `HEALTHCHECK`. Not yet run by CI; an existing long-lived container reports unhealthy until rebuilt (#1020) + +### 📦 Dependencies + +- Backend: `strands-agents` 1.51.0 → 1.55.0, `strands-agents-tools` 0.8.6 → 0.8.8. Transitively `aws-sdk-bedrock-runtime` 0.5.0 → 0.11.0, `smithy-core` 0.4.0 → 0.8.1, `smithy-aws-core` 0.5.0 → 0.11.0. `boto3`/`botocore` deliberately unchanged at 1.43.68 +- Fine-tuning (VLM training image only, via `requirements-vlm.txt`): `peft` 0.20.0, `bitsandbytes` 0.50.2, `pillow` 12.3.0 +- Load suite (isolated uv project, not in the backend image): `locust` 2.46.4 + +### 📚 Docs + +- `docs-site` fine-tuning page documents the generative task, the `vlm` DLC family, per-family script packaging, and replaces the paragraph stating generative VLMs are not supported with a section on why they are LoRA-adapted rather than fully fine-tuned (#1014) +- Managed-KB HANDOFF records task 16.2 as understood-with-no-code-fix: a managed KB retrieves from an image-only PDF where legacy returns nothing, but the vision model flattens a 2-D column layout, so per-column answers are confidently wrong — and the 16.1 cap increase does not rescue it at any cap, because more context cannot restore a coordinate never captured (#1017) + ## [1.19.1] - 2026-09-07 A patch release. Two MCP Apps defects made an App look broken while the tool behind it had really run: an app-initiated `tools/call` relayed an empty result back to the iframe, and a call made between turns hit a torn-down MCP session and came back as a 502. Both are fixed at the dispatch boundary. The artifact library listing now serves from `UserArtifactsIndex` instead of the base table, which retires the ~3x read amplification and the per-request in-memory sort. **No CDK deploy and no infrastructure change** — but the index that 1.19.0 shipped as groundwork is now on the read path, so its backfill has moved from optional to **required before deploying**. diff --git a/README.md b/README.md index 70720035b..17c9fc933 100644 --- a/README.md +++ b/README.md @@ -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.19.1-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.20.0-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) @@ -296,7 +296,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.19.1 +**Current release:** v1.20.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 64c2bd30d..59dbf558b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,306 @@ +# Release Notes — v1.20.0 + +**Release Date:** September 9, 2026 +**Previous Release:** v1.19.1 (September 7, 2026) + +--- + +> 🏗️ **CDK deploy required.** A new Lambda, its role and log group, an EventBridge rule and one SSM parameter for the managed-KB document reconciler; per-model Bedrock quota alarms replacing one account-wide alarm; and a new app-api task-definition revision. **No new table and no GSI operation on any existing table** — nothing has to reach `ACTIVE` before this deploy is safe. +> +> 💸 **Default app-api Fargate sizing doubles.** `appApi.cpu` 512 → 1024 and `appApi.memory` 1024 → 2048, per task, at 2 tasks. **A fork that sets nothing pays roughly twice as much for app-api compute after this deploy.** Set `CDK_APP_API_CPU=512` and `CDK_APP_API_MEMORY=1024` to keep the old sizing. See Deployment notes. +> +> 🔔 **One alarm disappears and is not automatically replaced.** `{prefix}-bedrock-tpm-quota-usage` is deleted because it was in ALARM roughly 99% of the time and accounted for very nearly all traffic on the alarm topic. Its replacement is per-model and **opt-in**, so an operator who configures nothing loses the leading indicator for quota pressure until they set `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS`. +> +> 🌐 **A nightly Lambda starts running for every fork, regardless of feature flags.** The managed-KB document reconciler's construct is instantiated unconditionally, so the schedule is created and enabled even with every `managedKb` flag off. It is **report-only** and read-only unless explicitly armed — but it does perform a full `Scan` of the RAG assistants table each night. +> +> 🧰 **`browse_web` will not appear until the bootstrap seeder is re-run.** The tool ships registered in code but inert, because tool availability is driven by the DynamoDB catalog. The Seed Bootstrap Data workflow is `workflow_dispatch`-only and is **not** invoked by any deploy pipeline. + +--- + +## Highlights + +Two capabilities the platform simply did not have, and one measurement that changes how it should be scaled. **`browse_web`** lets the agent drive a real Chrome browser in the AgentCore Browser sandbox — navigate, read, click, type, screenshot, and hand the user a live view URL to watch the session through. **Generative VLM fine-tuning** adds a fourth task type: instead of classifying an image into a fixed label set, a model can now be taught to *write* about one in an institution's own vocabulary, LoRA-adapted over a 4-bit base so a 34B checkpoint fits on hardware that a full fine-tune would need 550 GB for. Alongside them, a new load-testing harness answers a question nobody could previously answer with evidence, and the answer is uncomfortable: **the campus-scale ceiling is the Bedrock TPM quota, not compute** — a representative production turn consumes ~26,700 quota-counted tokens, so the untouched default quota supports about 225 turns per minute platform-wide and a single 300-student class exceeds it. + +Three fixes are worth reading even if you don't use the features they sit under. MCP Apps reach the end of a three-part chain that made every button in an embedded App fail silently after a page reload — and the middle link turned out to be that **AgentCore Runtime rewrites any non-2xx container response to a generic 424 and throws the body away**, which is a constraint on every inference-api handler that reports an error by HTTP status. A managed knowledge base could give confidently wrong answers from documents it had retrieved correctly, because a context cap sized for one chunker silently collapsed a five-chunk retrieval to one. And a security fix closes a sandbox escape in the Calculator tool's expression allowlist, which is enabled by default for every user. + +## `browse_web` — the agent drives a real browser + +The agent can now use a real Chrome browser rather than fetching a URL and hoping the content is in the HTML. Nine actions, one per call: `navigate`, `extract_text`, `extract_links`, `click`, `type`, `evaluate`, `screenshot`, `live_view`, and `close`. `live_view` returns a URL the user can open to **watch the browser session as it happens**, which turns an opaque multi-step automation into something observable. The tool's own docstring positions it as the expensive fallback to `fetch_url_content`, not a replacement for it. + +Two things to be clear about, because the platform's older Browser documentation describes something else. This is **not** Nova Act — there is no separate vision or action model and no new API key. The conversation model itself chooses each action, and the tool is a thin protocol driver. And `BROWSER_TOOL_ENABLED` is a **kill switch that defaults to on**, not an opt-in; what actually keeps the tool dark is that it is seeded `enabledByDefault: False` and, until the catalog row exists, is not offered at all. + +### Backend + +- `backend/src/agents/builtin_tools/browser/` — new package, three modules, **zero new dependencies**. `browse_tool.py` is the `@tool(context=True)` entry point and its dispatch; `cdp_client.py` is a hand-rolled Chrome DevTools Protocol client (`CdpSession`, `connect`, `command`, `evaluate`, `navigate`, `screenshot`); `session_pool.py` manages acquisition, reconnection, idle reaping and live-view URLs. +- **Why raw CDP instead of Playwright.** `websockets` is already in the inference-api image as a transitive dependency of `bedrock-agentcore`. Playwright would have added a bundled Node driver to the container for a capability the protocol already provides. The trade-off is stated rather than hidden: interaction is JavaScript-in-page, so `click` calls `el.click()` and `type` sets `el.value` then dispatches `input`/`change` with `bubbles: true` to make Angular and React state update. Pages that need genuinely trusted input — drag, some canvas widgets, some anti-bot forms — will not work. +- **A rebuilt agent does not start a second browser.** The session identity persists on Strands `agent.state` under `browser_tool.session` as `{sessionId, identifier, startedAt}`, while the live socket stays process-local. A second cached agent for the same conversation reconnects to the same remote session rather than starting — and billing — another one. boto3 calls are wrapped in `asyncio.to_thread` behind a per-session lock. +- **Budgets exist because the model pays for them in tokens.** Page text caps at 8,000 characters (~2k tokens), `evaluate` results at 4,000, links at 50; truncation messages tell the model to narrow with a selector rather than re-reading. Screenshots are never automatic — `navigate` returns text, and an image requires an explicit `action="screenshot"`. Remote sessions expire at 900s against an AgentCore allowance of 8 hours, and a local idle reap at 600s closes the socket *and* stops the remote session. +- **URL validation runs before the session pool is touched**, so a refused URL costs nothing. Scheme must be http/https, and `localhost`, `127.0.0.1`, `::1`, `169.254.169.254` (EC2 IMDS) and `metadata.google.internal` are denied outright. There is deliberately **no domain allowlist** in this release: defence-in-depth rests on the browser running in AWS with PUBLIC network mode, unable to reach the deployment VPC. An institution that wants a URL allowlist does not get one yet. + +### Infrastructure + +**None — and that is the point.** Every resource this needs already existed. `browser-construct.ts` creates the AgentCore Browser, `inference-agentcore-construct.ts` already sets `BROWSER_ID` on the runtime, and the runtime role already carried `ConnectBrowserLiveViewStream` alongside the session APIs, which is why `live_view` works with no IAM change at all. Before this commit the only readers of `BROWSER_ID` were `.env.example` and a startup log line. + +### Test Coverage + +381 lines in `backend/tests/agents/builtin_tools/browser/test_browse_tool.py`, with no AWS contact: a `FakeWebSocket` that actually speaks CDP covers protocol framing (browser-scoped vs page-scoped commands, target creation, page exceptions surfacing as `CdpError`, idempotent close failing pending futures), seven parametrized URL-validation cases, and nine dispatch tests — including the metadata endpoint being refused *without the pool ever being touched*, and the kill switch short-circuiting before session acquisition. + +**Not yet validated against live AWS.** The SigV4 WebSocket handshake, the browser-level CDP endpoint and a real page load cannot be exercised by unit tests, and the authoring session's SSO token had expired. `backend/scripts/probe_agentcore_browser.py` exists to run exactly those five checks (`--discover`, `--keep`); treat it as the verification step before enabling the tool for users. + +## Generative VLM fine-tuning + +Fine-tuning gains a fourth task type, **Image + text to text**. The three existing tasks all end in a softmax over a fixed class list, so the platform could tell you *which* of your labels an image matched but never produce prose about it. A generative VLM can be taught to answer a question about an image in an institution's own style and vocabulary — reading a form, describing a diagram, drafting a caption — and the pre-flight now accepts any Hub checkpoint tagged `image-text-to-text`, so users can bring their own. + +Five models are curated, with per-model defaults chosen rather than inherited: + +| Model | Size | Default instance | Notable override | +|---|---|---|---| +| `HuggingFaceTB/SmolVLM-Instruct` | 2.2B | ml.g6e.xlarge | `load_in_4bit=false` — bf16 is faster below ~3B | +| `llava-hf/llava-1.5-7b-hf` | 7B | ml.g6e.xlarge | — | +| `llava-hf/llava-v1.6-mistral-7b-hf` | 7.6B | ml.g6e.xlarge | `context_length=2048` for AnyRes tiling | +| `Qwen/Qwen2.5-VL-7B-Instruct` | 8.3B | ml.g6e.xlarge | `context_length=2048` | +| `llava-hf/llava-v1.6-34b-hf` | 34.8B | ml.g6e.4xlarge | `grad_accum=16`, `lora_r=8` | + +LLaVA-1.6 and Qwen get a doubled context length because AnyRes tiling can emit up to 2,880 image tokens against LLaVA-1.5's fixed 576. The 34B model's catalog copy warns to expect multi-hour runs and budget accordingly. + +### Backend + +- **LoRA, not a full fine-tune, and the arithmetic is the reason.** At roughly 16 bytes per parameter once gradients and AdamW moments are resident, a 34B full fine-tune needs ~550 GB against the 384 GB on the largest instance offered. The frozen base is quantised to 4-bit NF4 (double quant, bf16 compute) and only adapters train. **The artifact is therefore an adapter of a few hundred MB, not a model**: `vlm_adapter.json` records the base model id, quantisation and generation settings, and `model_fn` rebuilds base-plus-adapter with `PeftModel.from_pretrained`, pulling the base from the Hub. +- **Loss is masked to the response span.** Labels are set to `-100` at pad positions, non-attended positions, image-placeholder token ids, and the whole prompt prefix. Prompt length is measured by rendering each record through the processor's chat template **twice** — once with `add_generation_prompt=True` and no answer, once complete — so the count is a true prefix in the same tokenisation rather than an estimate. `prompt_mask_limit()` clamps to `len-1`, because an all-masked row yields NaN loss that poisons the batch average. +- **No invented chat format.** `render_chat()` raises if a checkpoint ships no chat template rather than guessing a dialogue shape the model never saw during pre-training. +- **`check_collation()` collates two records before `Trainer` starts.** The failure it exists to catch is truncation cutting into the image-placeholder run, which makes the placeholder count disagree with the image-feature count and dies in the first forward pass — minutes into a billed GPU, after tens of gigabytes of weight downloads. It re-raises with actionable guidance instead. +- **Batched generation is deliberately not attempted** (`GENERATION_BATCH_SIZE = 1`). It needs left padding plus a per-architecture agreement about where image placeholders sit relative to the pad run, and getting that subtly wrong produces fluent garbage rather than an error — the worst possible failure for a result file a researcher will treat as data. +- **Per-family script packaging.** `bitsandbytes==0.50.2` requires torch ≥ 2.4 and the text DLC is torch 2.1, so a single shared requirements file would have broken dependency installation for **every existing text job**. `scripts/sourcedir.tar.gz` becomes `scripts/sourcedir-{text,vision,vlm}.tar.gz`, each carrying its family's requirements packaged under the only filename the DLC installs from. The VLM family maps to the *same* DLC images as vision — the family selects the dependency set, so a future VLM-only image bump cannot re-baseline the image classifiers. +- The result contract is unchanged in shape. `output_fn` branches on `"generations" in prediction` rather than a caller-supplied flag, emitting `image,prompt,output` through the existing CSV escaping. **The download path and the result viewer needed no changes at all.** + +### Frontend + +- `create-training-job.page.ts` gains `isGenerative()` and `showImageSize()`, three new controls (LoRA Rank, LoRA Alpha, Quantization) seeded from the model's `default_hyperparameters`, and submits them **only** for a generative task so a classifier's job record carries no dead keys in its hyperparameters panel. +- The Image Size field is now hidden for generative tasks. The generative trainer never reads `image_size` — the model's own processor decides tiling and resolution — so leaving it visible would have been a control that silently did nothing. + +### Infrastructure + +None. No CDK deploy for this feature. Two operational notes: the first job of each family writes its own `sourcedir-{family}.tar.gz` under the existing `scripts/` prefix in the same bucket, so no IAM or bucket-policy change is needed and the old `scripts/sourcedir.tar.gz` is simply orphaned; and `pricing.py` already carried both g6e instance types, so the dollar-quota path works unchanged. Operators may still need a **SageMaker service-quota increase for `ml.g6e` training instances**, which no code change can supply. + +### Test Coverage + +665 lines across five files. `test_vlm_task.py` (285 new lines, ~45 tests) verifies the module imports without torch present and is registered in **both** the training and inference dispatchers, that `render_chat` raises rather than inventing a format, that `prompt_mask_limit` never masks the final position, and that a disabled quantisation config returns `None` *without importing bitsandbytes*. `test_script_packaging_service.py` asserts the VLM archive carries the peft stack and the text archive does **not** carry bitsandbytes. `test_task_types.py` pins the generative tag out of the dual-encoder task. `test_inference_script.py` covers embedded newlines, doubled quotes and commas not splitting a row. `create-training-job.page.spec.ts` (113 lines) covers the conditional controls both ways. + +The honest gap: torch, transformers and peft are absent from the backend venv by design, so the collator itself cannot be unit tested. The masking arithmetic was extracted into a pure function and covered there; the collator is guarded at runtime by `check_collation`. + +## An App's tool calls, and the 424 that ate their errors + +Every button in an embedded MCP App failed after a page reload, and failed *silently* — the user saw the MCP server's own "isn't connected yet" text rather than anything resembling an auth error. Fixing it took three passes, each of which only became visible once the previous one landed, and the middle one is a finding that applies well beyond MCP Apps. + +**Why the calls were unauthenticated.** An app-initiated `tools/call` runs `dispatch_app_tool_call`, which speaks to the MCP client directly instead of running the agent's tool loop. Strands therefore never raises `BeforeToolCallEvent`, so `OAuthConsentHook` — the only thing in the system that warms `oauth_token_cache` — never ran. The client's token provider is a pure cache read, so it resolved to `None` and the request went out **with no `Authorization` header at all**. Two things conspired to make this quiet: a server that accepts an unauthenticated `initialize`/`tools/list` (Google Tasks does) still registers its tools, so the App rendered perfectly and only the calls failed; and because no 401 came back, the existing 401-triggered recovery that *would* have warmed the cache from the vault never fired. A server that 401s its `tools/list` would have self-healed. The trigger is any container that has not served a model-driven turn for that user and provider — a reload onto a fresh runtime, a local restart, or a lapsed 3000s cache TTL. + +`_ensure_oauth_token` now repeats the hook's warm-the-cache half before dispatch, and distinguishes three outcomes rather than two: an unresolvable provider means "couldn't ask AgentCore", not "user must consent", so the call proceeds unauthenticated instead of false-prompting a connected user. A genuine consent requirement raises **409, never 401** — the SPA's error interceptor treats any 401 as an expired BFF session and would sign a user out over an unconnected connector. An auth-shaped failure clears the cached token and deliberately **does not retry**, because an app call is whatever button the user pressed (`complete_task`, `delete_event`) and a regex-triggered retry could apply a mutation twice. `AUTH_FAILURE_PATTERN` moved to `apis/shared/oauth/auth_failure.py` so the hook and the dispatch cannot drift on what an auth failure looks like. + +**Why nobody saw the 409.** Because it never arrived. inference-api runs behind AgentCore Runtime, **which rewrites any non-2xx container response to a generic 424 and discards the body**. The status and the message were destroyed before app-api ever saw them, and the user got: + +> Error — Received error (424) from runtime. Please check your CloudWatch logs for more information. + +This had made three separate in-repo comments false — one claiming statuses were relayed "verbatim (403 not-app-visible, 409 no consent)", one claiming the SPA relayed `message` verbatim — and it cannot reproduce locally, because a local uvicorn talks to app-api directly with no AgentCore in the path. Errors now cross the boundary as **HTTP 200 plus an `appToolError` envelope**, which app-api unwraps back into the real status. The envelope enforces two properties independently of its callers: an unlisted or malformed status collapses to 502 rather than letting upstream choose a 401, and because an envelope now arrives *with* a 200, the envelope check is placed **before** the provenance-card write so an enveloped error still persists no card. The SPA contract is unchanged — only the one hop that crosses AgentCore changes shape. + +**Why the restored message still didn't render.** app-api returned `{"error": ""}`. `ErrorService.handleHttpError` looks for a message in three places: a top-level string `detail`, an `error` key whose value is an **object** carrying `.detail` or `.message`, or a top-level string `message`. A string-valued `error` matches none of them, so `userMessage` stayed undefined and the generic per-status fallback rendered while the real text sat unread in the body. The sharpest part of this is the inversion: AgentCore's 424 body used the key `message`, which *did* match — so "check your CloudWatch logs" was rendered for precisely the reason the useful text was not. The body now carries `detail` alongside `error`, one key for each of the two independent consumers, and needed no SPA change. + +The toast now reads: *Authorization required for 'google-tasks'. Connect the account, then try again.* + +### Test Coverage + +546 lines across the three commits. 30 parametrized guards on the envelope itself, relay tests asserting the real status is restored, that a 401 is never relayed, and that an enveloped error persists no card (with a success control proving the check doesn't swallow success); nine async dispatch tests covering cold-cache warm from the vault, warm-cache skip, consent-to-409 with no dispatch, a disconnected user bypassing a cached token, and an auth-shaped failure clearing the token in exactly one call with no retry. + +## MCP Apps stop forgetting + +Four changes to how App frames live in a conversation, grouped because they are one story: the host was treating an App as a render-time artifact when it is really a stateful thing with a lifecycle. + +**Leaving a conversation and coming back dropped the App to a plain tool card.** Only a hard refresh brought it back. Two mechanisms collided: the session page called `McpAppStateService.reset()` on every route change, and the only thing that re-seeds the registry is the `uiResources` sidecar on `GET /messages` — a request `loadMessagesForSession` deliberately **skips** once a conversation's messages are cached, with no eviction. So every visit after the first reset the registry with no path back, and the inline `ui_resource` SSE event never re-streams. A hard refresh worked only because it destroyed the message cache. The registry is now keyed `sessionId → toolUseId → resource` and never reset on navigation; readers pass the viewed session id, writers pass the streaming session's own id. That second detail fixes a related loss: an App produced by a conversation streaming **in the background** used to be discarded permanently by an `isViewedSession` gate that existed only because of the reset. + +**An MCP server shipping a new App version couldn't reach existing conversations.** The stored `UIRES#` row was replayed verbatim forever, which had quietly made the platform the durable store of record for a resource that belongs to the server — including the CSP and permissions the App runs under. An app-initiated tool call now schedules a background revalidation: re-read the resource from the server, rewrite the row, preserve `produced_by_message_index` so a refresh cannot renumber where the frame sits in the thread. It is piggybacked on interaction rather than run on conversation open because re-reading needs a live MCP client, the only path to one is a built agent, and revalidating on open would add a full agent rebuild to a page load that runs no model turn — for every App, whether or not anyone touches it. The consequences are bounded deliberately: the refreshed shell lands on the **next** page load, the work never runs on the response path, a read that fails or returns no HTML leaves the old copy intact, and a new projection-limited `get_provenance` avoids pulling the ~130KB HTML it is about to overwrite. One side effect worth knowing: because `store()` recomputes `ttl` from now, each revalidation extends that row's 90-day expiry. + +**An App that saves its state on teardown wasn't getting to.** `dispose()` sent `ui/resource-teardown` and then removed the `message` listener in the same tick, so the View's ack landed on a removed listener and an App that answered teardown by calling a save tool had its `postMessage` dropped. Compounding it, teardown only fired from the frame's `onDestroy`, by which point Angular is removing the iframe in the same tick. `dispose()` now returns a promise that resolves on the ack or after a 1500ms grace window, gating inbound messages on a new `detached` flag rather than `disposed` — the window is live on purpose. A new `McpAppTeardownService` fires `teardownAll()` from the conversation-change effect while the iframe is still alive, swallowing individual rejections so one hung App can't strand the others. This matters more than it looks because the host is deliberately *not* the store of record for App state: a teardown the App never hears about is state nobody saves. Honest limits: a hard refresh or tab close still gets no window, and reparenting the iframe was rejected because moving an iframe in the DOM reloads it, destroying the very state being saved. + +**Reloading a conversation with an interactive App produced a wall of static cards.** An App that runs a tool on nearly every user gesture turned the tail of the thread into unbounded provenance noise, detached from where the calls happened and duplicating state the re-mounted App already shows. Those calls now surface on the frame that ran them as a header chip — `3 actions`, or `3 actions · 1 failed` in the danger token — expanding to one collapsed success summary (`board_snapshot ×6, update_task ×2`) with each failure listed separately alongside its error text. Cards whose frame can't render keep a standalone fallback box. Grouping works because a card's `toolUseId` is the *originating* call that produced the App's `ui_resource`, not a per-call id. This remains provenance-only — none of it reaches the model or the prompt. + +### Test Coverage + +470 lines across the four changes, including an identity assertion that a `cardsFor` miss returns the same frozen array reference (so a miss doesn't churn a computed every change-detection pass), a rewritten bridge test that now asserts the grace window where it previously asserted the same-tick detach, and coverage that a `tools/call` made *in response to* teardown is still proxied while one made after the window is ignored. + +## Stranded knowledge-base documents get found + +A user uploads a document to a managed knowledge base. They are told it worked. The content really is indexed in Bedrock and really is retrievable. And the assistant will **never** cite it — permanently, silently. This happened twice in dev and both were repaired by hand. + +`DOC#` status has exactly one writer, the ingestion consumer. Lambda's async retry is capped at **two** attempts, a hard service limit; when an event dead-letters, the row is left non-terminal (`uploading`, `chunking`, `embedding`) with nothing remaining to revisit it — while Bedrock, indifferent to the consumer's fate, often finished indexing seconds later. Because the retrieval filter serves only `complete` documents, every chunk of that document is dropped from every query. + +### Backend + +- `document_reconciler.py` walks KB_Records, skips anything not managed or lacking an `awsKbId`, and pages `DOC#` rows to exhaustion. Candidates are the three non-terminal statuses; `complete`, `failed` and **`deleting`** are never candidates, because resurrecting a soft-deleted document would be a data-governance failure rather than a repair. +- **The grace gate is a pure function of the row's own `updatedAt`, never of discovery time**, and returns "not stuck" on an unparseable or absent timestamp — fail-safe by construction. +- Classification reuses the ingestion consumer's own probes rather than reimplementing them, so the two cannot drift: in-flight is left alone; a FAILED-family status is marked `failed`; INDEXED or partial gets a **retrievability check first**, and only then `complete`. A document that Bedrock calls indexed but that does not actually come back from a retrieval is deliberately left short of `complete`. `NOT_FOUND` is re-ingested from the row's own `s3Key`. An unrecognised status is treated as in-flight and logged. +- The retrievability check takes **one reading, not a poll**, and carries the load-bearing `{"equals": {"key": "document_id", "value": …}}` filter — an unfiltered probe confirms the wrong document. +- **`lambda_handler` ignores an `armed` field in the event and logs a warning**, so `lambda:InvokeFunction` alone cannot turn on writing. Arming is an environment variable, deployed deliberately. +- Bounds throughout: 25 actions per run by default (ceiling 100), 5,000 records per run, and the action limit is **applied in report-only mode too** so that the report is a trustworthy prediction of what an armed run would do. +- Five new `{prefix}/ManagedKb` metrics: `KbStrandedDocumentsFound`, `…Completed`, `…Reingested`, `…Failed`, `KbDocumentReconcilerLimitReached`. Found and LimitReached emit in both modes; the three action metrics only on an armed successful write. + +### Infrastructure + +New `KbDocumentReconcilerLambda` (ARM64, 15-minute timeout, 512 MB) sharing the one byte-stable kb-migration image asset, plus its role, its log group, an EventBridge rule at `cron(0 9 * * ? *)` — 09:00 UTC, roughly 02:00–03:00 America/Denver — and SSM parameter `/{prefix}/kb-migration/document-reconciler-function-name`. IAM is exactly `assistantsTable` read/write, `documentsBucket` **read-only**, and the existing `ManagedKbDirectIngestion` and `ManagedKbRetrieve` grants; provisioning rights and `iam:PassRole` are deliberately withheld and asserted absent by tests. **No new table and no GSI operation on any existing table** — this reuses the RAG assistants table. + +Two things operators should know that the commits do not say. **No CloudWatch alarm covers this Lambda** — it is absent from the `LambdaAlarmsConstruct` function list, and none of the five new metrics has an alarm, so a failing nightly run pages nobody; check the log group after the first few nights. And the run performs a **full `Scan`** of the RAG assistants table, because the KbWorkIndex GSI is sparse and deliberately excludes these records — that cost is incurred nightly even with zero managed KBs, on top of the existing daily KB reconciler scan. `MAX_RECORDS_PER_RUN` bounds records *yielded*, not items scanned. + +### Test Coverage + +677 lines in `test_kb_document_reconciler.py` — 61 collected cases across 13 classes, with no AWS contact (moto plus a stub modelling Bedrock's document view). Coverage includes the arming flag, the grace gate, each terminal transition, terminal rows being ignored, per-run action limits, the retrievability probe's filter, and a mixed run. Infrastructure tests add the nightly rule targeting the right Lambda, the rule being **enabled with every flag off**, and arming independence in both directions — arming the document reconciler does not arm the KB reconciler, or vice versa. + +## Campus-scale load testing, and the ceiling it found + +The interesting output of this work is not the harness. It is a number. + +Against production with Claude Sonnet 5, **a representative turn costs ~26,700 quota-counted tokens** — 384 input, **24,926 cache-read**, 1,372 output — against the ~1,920 that the harness's own naive default profile produces. Cache-read tokens count against the Bedrock TPM quota, which is what makes the gap roughly **14×**: across eight observed invocations, quota-counted tokens ran 16× the sum of input and output alone. At the applied **6,000,000 TPM** quota — the untouched AWS default — that puts the platform-wide ceiling at about **225 turns per minute**, which **a single 300-student class exceeds**. A load test built on the naive profile would have passed comfortably while describing a production workload that throttles. + +The consequence is a capacity conclusion no amount of Fargate tuning addresses: raising Sonnet 5's TPM quota from 6M toward ~40M for 1,300 concurrent users is a Service Quotas request, not a code change. + +### Backend and tooling + +- `tests/load/` is a **separate uv project** with its own lockfile. Putting Locust in `backend/pyproject.toml` would have landed it in `backend/uv.lock` and in the app-api and inference-api image dependency resolution. +- Users log in through the **real Cognito Hosted UI** authorization-code flow, because `/chat/stream` is cookie-only since the BFF migration — no token can be minted with `initiate-auth`. A generic `HTMLParser` finds the form with a password input and resubmits every hidden field rather than hardcoding Cognito's field names, and password values are never captured. +- **The metrics that matter are measured client-side.** The native `POST /chat/stream` row measures time to response *headers* only, which for a streamed response is nearly meaningless. Two custom metrics — `SSE chat: time to first token` and `SSE chat: full turn` — are fired from the SSE reader instead. This is the same reasoning the observability guidance gives for why ALB `TargetResponseTime` is a weak signal on this path: the ALB does not consider the request complete until the stream closes. Failure-inside-200 is caught too: a `stopReason: "error"` on `message_stop`, or a stream ending without `done`, both record as turn failures. +- `validate_host()` hard-fails a non-https target, because the `__Host-`prefixed session and CSRF cookies are Secure-only and `requests` silently drops them — producing a login that appears to succeed and then 401s on every turn. +- **A `CredentialPool` refuses to start an under-provisioned run.** A `test_start` listener compares Locust's user count to the pool *before the first login* and quits with one clear message, rather than letting users die one at a time mid-ramp. Sharing identities is not merely untidy: shared users share a `user_id`, so session and cost writes collide on one DynamoDB partition, one quota counter and one memory namespace, and the run partly measures its own key collisions. The opt-out is explicit (`AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE=1`, strict truthiness so a typo leaves the safe default). +- `ClassroomBurstShape` runs baseline → spike → hold → drain, twice, 840 seconds at defaults (20 baseline users, spikes to 300 over 30s). The second burst is the point: the first hits cold tasks, an empty prompt cache and 60s scale-out cooldowns, which makes "survives the 9am class" and "survives a class following a class" separately answerable. +- The representative profile enables 12 of 28 production tools **to inflate the cached prompt prefix, not to be called** — the prompts are general-knowledge questions answerable from weights, because firing Canvas, PeopleSoft and Brave Search at 300–1300× concurrency would load institutional and third-party systems irrelevant to the measurement. +- `scripts/load-test/watch-tpm.sh` is read-only and reports tokens/min, percentage of the **applied** quota, turns/min and implied tokens/turn, flagging `/UNREPRESENTATIVE` when implied tokens/turn falls below 10,000 — that is, when the profile is lying. It reads 5-minute windows because one observed production minute reported 546,206 quota tokens against a single invocation, more than that model's context window, so per-minute peaks are untrustworthy. It also **warns when the applied quota still equals the AWS default**, meaning no increase ever landed. +- `scripts/load-test/provision.sh` and `teardown.sh` exist because two platform safety rails correctly block a load test and neither is workaroundable test-side: `FORCE_CHANGE_PASSWORD` prevents scripted Hosted-UI login, and per-user cost quotas hard-stop sustained traffic, after which the run measures quota enforcement rather than the chat path. Like `set-bsu-overrides.sh`, these are **not run by CI**, require confirmation, and support `--dry-run`. + +**These scripts mutate live AWS state and teardown is mandatory.** They create real users in the same Cognito pool real people use, and write `unlimited` quota overrides — a **disabled cost control** on a real `user_id`, visible in the admin dashboard under Quota Overrides. Safety rails are worth naming: every manifest username must begin with `loadtest-`, validated across the whole file in one pass **before any delete**, so a hand-edited manifest cannot delete real users nor a subset before failing; overrides are deleted before users; credentials never appear in argv; and the manifest is created empty and `chmod 600` *before* any password is written, outside the repo tree. + +Four incidental bugs surfaced along the way, all in the harness rather than the platform: `GET /costs` 404'd every read-only iteration (the router has no root route — the original "verified present" claim was simply wrong, and a 404 fails at near-zero cost while inflating the error rate), `GET /tools` paid a 307 round trip on every call, `provision.sh` reported an unset `AWS_PROFILE` as a wrong project prefix because it discarded AWS's stderr, and the prompts loader stripped blank lines but not comments, so a documented prompts file would have sent its own header to the model as a user turn. + +### Test Coverage + +608 lines, 53 tests, all pure logic with no AWS, network or load: SSE parsing, config validation, the login-form parser, credential-pool uniqueness and worker partitioning, and the burst shape's timeline, spawn-rate arithmetic and validation. The **provisioning scripts have no automated tests** — verification was manual, and the AWS calls themselves are unexercised. + +## 🐛 Bug fixes + +- **A managed knowledge base answered confidently and wrongly from documents it had retrieved correctly.** A Major-Core course was described as an elective; on an advising corpus only one of four emphasis areas came from the documents and the other three were invented. `MAX_CONTEXT_CHARS = 2000` was a single shared default adopted "for parity", but Bedrock's chunks are roughly 3× larger than Docling's and the cap truncates per accumulated chunk — so a requested `top_k=5` fit about four legacy chunks and only **one** managed chunk. It was silently a `top_k=1` retrieval, and the neighbours it dropped carried the section header that gave the remaining chunk its meaning. The evaluation that had justified the shared cap covered single-fact lookups only and had explicitly flagged multi-chunk synthesis as untested. Managed KBs now resolve an 8,000-character cap through `resolve_context_cap`, keyed on the same engine resolution the backend selection already uses, so an absent or unreadable record still resolves legacy. All four emphasis areas now come from the documents. Costs roughly 966 extra input tokens per augmented turn on managed KBs (#997) +- **Retrieval could serve chunks whose parent document status was never verified — including deleted content.** `_filter_vectors_by_document_status` opened with `if not doc_ids: return vectors`. Because `doc_ids` is built per chunk and yields an empty string when the location and both metadata mirrors are absent, a **non-empty** batch in which every chunk resolved to an empty id produced an empty set and returned the vectors **unfiltered**, skipping the DynamoDB status check entirely. Every other unprovable branch in that function already returned `[]`; this one path was overlooked, against a requirement that already mandated failing closed. It now returns `[]` and emits `KbStatusFilterFailClosed`. The trade-off is intended: in that state a query returns nothing rather than unverified content (#998) +- **A managed-KB document showed the literal "Uploading" for the entire indexing wait.** The managed consumer writes only `uploading` and then `complete`, so the finer-grained legacy vocabulary (`Chunking`, `Embedding`) had nothing to describe. Managed documents now read `Processing` → `Ready`, with `Failed`; legacy keeps its own words. The engine is also now visible directly — a `Managed`/`Classic` badge on the Uploaded Documents panel, and one INFO line per retrieval naming the engine, which previously could only be answered by reading the KB record out of band (#1006) +- **Voice Mode would have silently switched off** on the `strands-agents` upgrade. 1.55.0 moved the Nova Sonic provider from `strands.experimental.bidi.models.nova_sonic.BidiNovaSonicModel` to `strands.experimental.bidi.models.bedrock.BedrockNovaSonicModel` and flattened its constructor. `voice_agent.py` imports the provider inside `except ImportError: BIDI_AVAILABLE = False`, so a rename is swallowed: voice off everywhere, no crash, one INFO log line. The rename is not in the upstream release notes. The import is now an explicit module import so a future rename fails loudly, and a contract test reads the pinned SDK's source from disk to assert the class, the flattened signature and the five audio-config keys still hold (#1012) + +## 🔒 Security + +**A sandbox escape in the Calculator tool's expression allowlist** — `strands-agents-tools` 0.8.6 → 0.8.8. + +The tool validates a model-supplied expression with an AST allowlist. A string literal is normally rejected, because a string reaching `sympify` gets re-parsed and defeats the restriction entirely. The allowlist carves out one exception: a string *is* trusted as a positional argument to a small set of constructors that parse it as a plain name or numeric literal — `Symbol`, `symbols`, `Rational`, `Integer`, `Float`. Through 0.8.6 that check looked **only at the positional argument and ignored the call's keyword arguments**. Because SymPy's `symbols()` accepts a `cls=` keyword naming the class to apply to each parsed name, `symbols('...', cls=N)` reroutes the string through `sympify` after all — outside the restricted namespace, while the allowlist believes a safe constructor is handling it. + +This was live rather than theoretical: `tool_registry.py` registers `calculator` on the default agent and the bootstrap seeder marks it `enabledByDefault: True`, so it is on for every user unless an admin turns it off. Upstream 0.8.8 trusts a string positional only when every keyword on the call is a boolean assumption flag, and treats `**kwargs` unpacking as untrusted outright since it can smuggle in `cls`. + +**No CVE or GHSA identifier was issued**; the fix is identified by the version boundary. Ordinary arithmetic and symbolic input is unaffected, and `Symbol('x', positive=True)` still passes. The upgrade was validated by diffing the two wheels: six files differ and only `calculator.py` is in this repo's import graph. + +101 lines of regression tests in `backend/tests/security/test_calculator_sandbox.py` — 17 executed cases including the disclosed form, a payload-carrying variant, the `**kwargs` route, and eight legitimate expressions guarding against the fix narrowing real use. The tests import from the *installed* wheel, so a resolver drift below 0.8.8 fails the suite rather than silently reopening the escape. + +**This ships through `backend.yml`, not a CDK deploy.** Bumping the pin without shipping a new inference-api image leaves the old wheel running. + +## ⚠️ Changed + +- **Default app-api Fargate sizing doubles.** `appApi.cpu` 512 → 1024 and `appApi.memory` 1024 → 2048 per task, at 2 tasks. Production had been running 2 × (0.5 vCPU, 1 GB) — 1 vCPU total — and reportedly saturated at around 100 concurrent logins, failing the container health check, pulling a task mid-burst and rejecting requests at roughly 13× latency. Sizing is now per-environment through GitHub Variables, which also fixes a real config-plumbing bug: the loader read only the **nested** `appApi` context object, while `load-env.sh` emits `--context appApi.cpu=…`, which CDK stores as the *flat* key `appApi.cpu`. A direct `cdk synth --context appApi.cpu=…` was therefore accepted and discarded. (In CI the environment variable was already winning, so the blast radius was narrower than it sounds.) **Set `CDK_APP_API_CPU=512` and `CDK_APP_API_MEMORY=1024` to keep the previous sizing** (#1020) +- **The account-wide Bedrock quota alarm is deleted, and its replacement is opt-in.** See Infrastructure below. A fork that configures nothing has no quota alarm after this deploy (#1016) +- **`McpAppBridge.dispose()` now returns `Promise`** and accepts an optional grace-period argument, and the bridge keeps serving inbound messages for up to 1500ms after teardown is requested. Internal to the SPA; relevant only to forks that have extended this class (#1003) +- **Fine-tuning script packaging is per-DLC-family.** `scripts/sourcedir.tar.gz` becomes `scripts/sourcedir-{text,vision,vlm}.tar.gz` in the same bucket and prefix, so no IAM or bucket-policy change is needed. The old object is orphaned — nothing reads or deletes it, and it can be removed by hand (#1014) + +## 🏗️ Infrastructure + +- **New managed-KB document reconciler Lambda, schedule, role, log group and SSM parameter.** Covered in the spotlight above. The two points to carry into a deploy plan: the construct is instantiated **unconditionally**, so the Lambda and the *enabled* nightly rule are created regardless of every `managedKb` flag — deliberate, because report-only is read-only and the audit period only happens if the schedule runs — and the run performs a nightly full `Scan` of the RAG assistants table even with zero managed KBs. Roughly 6–7 new CloudFormation resources; the 460-resource guard did not trip (#1007, #1008) +- **Per-model Bedrock TPM quota alarms.** `{prefix}-bedrock-tpm-quota-usage` is deleted and replaced by `{prefix}-bedrock-tpm-quota-usage-`, one per entry in `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS`, thresholded at `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT` (default 75) of that model's own quota. Net resource change is **−1** for a fork that configures nothing. The default map is deliberately empty: quotas differ by two orders of magnitude within a single account, most are adjustable, and any shipped number would be wrong for every fork and stale the first time someone requested an increase. `Maximum` statistic over a 5-minute period against 1-minute data, because the quota is per *minute* and averaging would dilute a real spike below the threshold. Each alarm's description leads with the instruction to confirm the configured quota is still the live one — **it is maintained by hand and nothing checks it** (#1016) +- `infrastructure/cdk.context.json` added to the `platform.yml` push-paths filter; a sizing edit there previously triggered no deploy (#1020) + +## 🔧 CI/CD + +- **`CDK_MANAGED_KB_DOC_RECONCILER_ARMED` is now forwarded** from `vars.*` in the platform deploy job's **job-level** `env:`. The flag had been threaded through `load-env.sh` and `config.ts` but never added to the workflow, so `load-env.sh` always saw it unset and never emitted the `--context` flag — the accept-then-silently-ignore failure this repo has now hit three times (#1018) +- New job-level `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT` and `CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS` (#1016) +- New job-level `CDK_APP_API_CPU`, `CDK_APP_API_MEMORY`, `CDK_APP_API_DESIRED_COUNT`, `CDK_APP_API_MAX_CAPACITY`. `nightly-deploy-pipeline.yml` pins 512/1024/2/4 **literally** rather than from `vars.*`, so a production sizing bump never inflates an ephemeral nightly stack — while `desiredCount` stays at 2 so the shared BFF cookie-key path is still exercised (#1020) +- **New `Test load suite (pytest)` job on every PR into `develop` and `main`**, plus `locust --list` on both locustfiles. The gate exists because the locustfiles encode the `/chat/stream` payload shape and the SSE event names, so renaming a stream event now fails CI instead of silently producing a load test that reports every turn as never finishing (#1020) +- `deploy-image-lambda-one.sh` and `backend.yml` gain a `kb-migration-document-reconciler` case (#1008) +- `shellcheck` 0.9.0 and `actionlint` 1.7.12 added to the dev container and its `HEALTHCHECK` — they compose, since actionlint shells out to shellcheck to lint workflow `run:` blocks. Neither is wired into CI yet; **an existing long-lived dev container will report unhealthy until rebuilt** (#1020) + +## 📦 Dependencies + +| Component | Package | From | To | +|---|---|---|---| +| Backend | `strands-agents` | 1.51.0 | 1.55.0 | +| Backend | `strands-agents-tools` | 0.8.6 | 0.8.8 | +| Backend (transitive) | `aws-sdk-bedrock-runtime` | 0.5.0 | 0.11.0 | +| Backend (transitive) | `smithy-core` | 0.4.0 | 0.8.1 | +| Backend (transitive) | `smithy-aws-core` | 0.5.0 | 0.11.0 | +| Backend (transitive) | `smithy-http` | 0.4.0 | 0.5.0 | +| Fine-tuning VLM image | `peft` | — | 0.20.0 | +| Fine-tuning VLM image | `bitsandbytes` | — | 0.50.2 | +| Fine-tuning VLM image | `pillow` | — | 12.3.0 | +| Load suite (isolated) | `locust` | — | 2.46.4 | +| Dev container | `shellcheck` | — | 0.9.0 | +| Dev container | `actionlint` | — | 1.7.12 | + +`boto3` and `botocore` are deliberately unchanged at 1.43.68 — 1.55.0 floors well below that and nothing forced a bump. The `strands-agents` upgrade also required two code adaptations beyond the Voice Mode rename: `cache_tools` is deprecated in favour of `CacheConfig(tools_ttl=…)`, and 1.55.0 auto-injects a system cache point guarded by a predicate equivalent to the factory's own — worth verifying because two adjacent cache points are a Bedrock `ValidationException` and a moved system boundary would rewrite the cached prefix and destroy hit rates. The upgrade was checked against dev Bedrock: a request formatted by 1.55.0 read the exact cache entry a 1.51.0-formatted request had just written, and a three-turn session went first-write → hit → hit with constant tool-config and system-prompt hashes. + +## 🧪 Test coverage + +Roughly **4,100 lines of new tests** across the release: + +| Area | Lines | Scope | +|---|---|---| +| KB document reconciler | 677 | 61 cases, 13 classes; moto plus a Bedrock document-view stub | +| Generative VLM fine-tuning | 665 | Task spec, trainer purity, per-family packaging, CSV escaping, SPA controls | +| Load suite | 608 | 53 tests: SSE, config, login form, credential pool, burst shape | +| MCP Apps error envelope + OAuth | 546 | 30 envelope guards, 9 async dispatch tests, relay/status restoration | +| MCP Apps lifecycle | 470 | Retention, revalidation, teardown grace, actions chip | +| `browse_web` | 381 | CDP framing via a fake that speaks the protocol, URL validation, budgets | +| Managed-KB engine visibility | 226 | Badge, status vocabulary, retrieval log line | +| `strands-agents` 1.55.0 | 153 | Provider contract read from the pinned SDK's source on disk | +| Bedrock quota alarms | 122 | Per-model dimensions, thresholds, context parsing, routing | +| Calculator sandbox | 101 | 17 cases against the installed wheel | +| KB cap + fail-closed | 82 | Cap resolution, engine keying, empty-`document_id` batch | +| app-api sizing config | 75 | Flat vs nested context precedence, unset variable falls through | + +Two claims in this release's commit messages are the authors' own methodology rather than independently reproduced here, and are worth reading as such: several describe their tests as "mutation-verified" without a mutation-testing config in the diffs, and the full-suite pass counts (7,940 backend / 2,558 frontend on the fine-tuning branch) were not re-run. + +## 🚀 Deployment notes + +**A CDK deploy is required**, and both backend images plus the SPA must ship. Order does not matter much, with one exception noted below. + +1. **Decide the app-api sizing before deploying Platform.** The committed default changed, so doing nothing is a choice with a bill attached. Set the four GitHub Variables on the deploy job's environment, then confirm the resolved values in the synth log — **changing a GitHub Variable triggers no workflow**, so deploy via `workflow_dispatch`. `cpu` and `memory` must remain a valid Fargate pair (1024 → 2048–8192; 2048 → 4096–16384); an invalid pair is a **failed CloudFormation deploy**, not a silent fallback. + + | Variable | Default if unset | + |---|---| + | `CDK_APP_API_CPU` | `1024` (was 512) | + | `CDK_APP_API_MEMORY` | `2048` (was 1024) | + | `CDK_APP_API_DESIRED_COUNT` | `2` | + | `CDK_APP_API_MAX_CAPACITY` | `10` | + +2. **Restore a Bedrock quota alarm if you want one.** The account-wide alarm is deleted on this deploy and nothing replaces it automatically. Read your live quotas, then set the map — **use the quote-free form**, because `deploy.sh` runs `eval npx cdk synth` and `eval` strips quotes: + + ```bash + aws service-quotas list-service-quotas --service-code bedrock \ + --query "Quotas[?contains(QuotaName,'tokens per minute')]" + ``` + + ``` + CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS=global.anthropic.claude-sonnet-5=40000000,us.anthropic.claude-sonnet-4-20250514-v1:0=200000 + ``` + + JSON is also accepted, but only survives because `load-env.sh` single-quotes it; a value containing a single quote now fails the script loudly rather than degrading to "no alarms, no error". Remember that the configured quota is a hand-maintained number that nothing verifies — `bedrock-invocation-throttles` remains the no-configuration backstop, and it fires on real refusals. + +3. **Expect the document reconciler to start running, and read its output before arming it.** The nightly rule is created enabled for every deployment. It is report-only and read-only until `CDK_MANAGED_KB_DOC_RECONCILER_ARMED` is explicitly truthy — unset, empty and `false` all mean off, at every layer. Give it a few nights, read the log group (there is **no alarm** on this Lambda and no alarm on its five metrics), confirm the proposed actions look right, and only then arm it. Armed runs are capped at 25 corrections per night by default. This flag is independent of `CDK_MANAGED_KB_RECONCILER_ARMED`; arming one does not arm the other. Between the CDK deploy and the next `backend.yml` run the rule invokes the no-op bootstrap stub, which is safe. + +4. **Re-run Seed Bootstrap Data if you want `browse_web`.** The tool ships registered in code but has no DynamoDB catalog row, and tool availability is driven entirely by that catalog — so without a seed run it never appears in the UI, is never enabled, and is never handed to the agent. The workflow is `workflow_dispatch`/`workflow_call` only and **is not invoked by any deploy pipeline**, so this will not happen as a side effect. The seeder is idempotent and skips existing tool rows. Once seeded, note the shape of the default grant: the seeded `default` role carries a wildcard tool grant, so the tool becomes *granted* to everyone while still requiring each user to enable it in their tool preferences. An institution that wants it restricted should grant per-role rather than rely on the wildcard. Consider running `backend/scripts/probe_agentcore_browser.py` first — the SigV4 WebSocket handshake and a real page load were not exercised against live AWS in this release. + +5. **Ship both backend images together.** The MCP Apps error envelope is a contract change between inference-api and app-api. `backend.yml` deploys both from the same commit so the steady state is fine, but a *new* inference-api behind an *old* app-api would relay `200 + {"appToolError": …}` straight to the SPA, where it reads as a successful call with an empty result. The reverse pairing is harmless. + +6. **No backfill, no migration, no index to wait for.** Nothing in this release adds or modifies a GSI, and no one-shot script needs running. + +Two things this release cannot do for you. If you intend to serve anything like campus scale, **request a Bedrock TPM quota increase now** — the measurement above puts the untouched 6,000,000 TPM default at roughly 225 turns per minute platform-wide, and quota increases have lead time. And if you plan to fine-tune VLMs, check your **SageMaker `ml.g6e` training-instance quota**, which the platform cannot raise on your behalf. + +--- + # Release Notes — v1.19.1 **Release Date:** September 7, 2026 diff --git a/VERSION b/VERSION index 66e2ae6c2..398935591 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.19.1 +1.20.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f85da038f..f13c76cb2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.19.1" +version = "1.20.0" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" @@ -56,8 +56,8 @@ dependencies = [ [project.optional-dependencies] # AgentCore-specific dependencies (for inference_api) agentcore = [ - "strands-agents==1.51.0", - "strands-agents-tools==0.8.6", + "strands-agents==1.55.0", + "strands-agents-tools==0.8.8", "aws-opentelemetry-distro==0.19.0", "bedrock-agentcore==1.21.0", @@ -71,7 +71,7 @@ agentcore = [ # Voice/BidiAgent dependencies (Nova Sonic speech-to-speech) bidi = [ - "strands-agents[bidi]==1.51.0", + "strands-agents[bidi]==1.55.0", ] # Document ingestion pipeline dependencies (for Lambda deployment) diff --git a/backend/scripts/probe_agentcore_browser.py b/backend/scripts/probe_agentcore_browser.py new file mode 100644 index 000000000..1e29cfedd --- /dev/null +++ b/backend/scripts/probe_agentcore_browser.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +"""Smoke-test the AgentCore Browser tool against real AWS. + +Verifies the parts unit tests cannot: that AgentCore's automation WebSocket +accepts our SigV4 headers, that it exposes a browser-level CDP endpoint where +`Target.getTargets` works, and that a real page navigates and evaluates. + +Usage: + cd backend + AWS_PROFILE=dev-ai BROWSER_ID= \ + uv run python scripts/probe_agentcore_browser.py + + # or let it discover the browser from the account: + AWS_PROFILE=dev-ai uv run python scripts/probe_agentcore_browser.py --discover + +Options: + --url URL Page to load (default: https://example.com) + --discover List custom browsers in the account and use the first + --keep Leave the session running and print the live-view URL + (otherwise the session is stopped on exit) + +Costs a browser session for as long as it runs. Stops it on the way out +unless --keep is passed. +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("probe") + + +def _discover_browser_id(region: str) -> str | None: + import boto3 + + client = boto3.client("bedrock-agentcore-control", region_name=region) + try: + response = client.list_browsers(maxResults=20) + except Exception as exc: # noqa: BLE001 + logger.error("list_browsers failed: %s", exc) + return None + summaries = response.get("browserSummaries", []) or response.get("browsers", []) + for summary in summaries: + identifier = summary.get("browserId") or summary.get("browserIdentifier") + name = summary.get("name", "") + logger.info("found browser: %s (%s)", identifier, name) + if identifier and not str(identifier).startswith("aws."): + return identifier + return None + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="https://example.com") + parser.add_argument("--discover", action="store_true") + parser.add_argument("--keep", action="store_true") + args = parser.parse_args() + + region = os.environ.get("AWS_REGION", "us-west-2") + + if args.discover: + discovered = _discover_browser_id(region) + if discovered: + os.environ["BROWSER_ID"] = discovered + logger.info("using discovered browser: %s", discovered) + + identifier = os.environ.get("BROWSER_ID") or "aws.browser.v1" + logger.info("region=%s browser=%s", region, identifier) + + from bedrock_agentcore.tools.browser_client import BrowserClient + from agents.builtin_tools.browser.cdp_client import CdpSession + + client = BrowserClient(region=region) + session_id = await asyncio.to_thread( + client.start, + identifier=identifier, + session_timeout_seconds=300, + viewport={"width": 1280, "height": 800}, + ) + logger.info("started session %s", session_id) + + cdp = None + try: + ws_url, headers = await asyncio.to_thread(client.generate_ws_headers) + logger.info("connecting to %s", ws_url.split("/sessions/")[0] + "/sessions/...") + cdp = await CdpSession.connect(ws_url, headers) + print("\n[1/5] CDP connected and attached to a page target ✅") + + await cdp.navigate(args.url) + print(f"[2/5] Navigated to {args.url} ✅") + + title = await cdp.evaluate("document.title") + print(f"[3/5] document.title = {title!r} ✅") + + text = await cdp.evaluate( + "(() => (document.querySelector('main') || document.body).innerText)()" + ) + preview = (text or "")[:200].replace("\n", " ") + print(f"[4/5] Page text ({len(text or '')} chars): {preview}... ✅") + + # The WebMCP hook: prove an init script runs before page scripts. + await cdp.add_init_script("window.__probe_init__ = 'ran';") + await cdp.navigate(args.url) + marker = await cdp.evaluate("window.__probe_init__ || 'MISSING'") + status = "✅" if marker == "ran" else "❌" + print(f"[5/5] Init script before page load: {marker!r} {status}") + + if args.keep: + url = await asyncio.to_thread(client.generate_live_view_url) + print(f"\nLive view (session left running): {url}") + return 0 + + print("\nAll checks passed.") + return 0 + except Exception as exc: # noqa: BLE001 + logger.error("probe failed: %s", exc, exc_info=True) + return 1 + finally: + if cdp is not None: + await cdp.close() + if not args.keep: + await asyncio.to_thread(client.stop) + logger.info("stopped session %s", session_id) + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/backend/scripts/seed_bootstrap_data.py b/backend/scripts/seed_bootstrap_data.py index 8477eb543..f22c191d9 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -402,6 +402,23 @@ def seed_default_models( "isPublic": False, "forwardAuthToken": False, }, + { + "toolId": "browse_web", + "displayName": "Web Browser", + "description": ( + "Browse the web in a real Chrome browser: navigate pages, read " + "JavaScript-rendered content, fill forms, and click through " + "multi-step flows." + ), + "category": "browser", + # Deliberately off by default. Each session bills an AgentCore Browser + # session on top of model tokens, and a browsing transcript is a large + # per-turn payload — this is opt-in per user, granted per role. + "enabledByDefault": False, + "protocol": "local", + "isPublic": False, + "forwardAuthToken": False, + }, { "toolId": "generate_diagram_and_validate", "displayName": "Code Interpreter", diff --git a/backend/src/agents/builtin_tools/__init__.py b/backend/src/agents/builtin_tools/__init__.py index 3c7ea2ed7..b06157a1f 100644 --- a/backend/src/agents/builtin_tools/__init__.py +++ b/backend/src/agents/builtin_tools/__init__.py @@ -2,9 +2,11 @@ This package contains tools that leverage AWS Bedrock capabilities: - Code Interpreter: Execute Python code for diagrams and charts +- Browser: Drive a real Chrome browser in the AgentCore Browser sandbox - Spreadsheet Analysis: Analyze tabular data via Code Interpreter (factory-produced, not in registry) """ +from .browser import browse_web from .code_interpreter_diagram_tool import generate_diagram_and_validate from .spreadsheet_analysis import make_list_spreadsheets_tool, make_analyze_tool @@ -12,5 +14,6 @@ # Factory-produced tools (make_list_spreadsheets_tool, make_analyze_tool) are created # per-request with context and injected via extra_tools — not registered here. __all__ = [ + 'browse_web', 'generate_diagram_and_validate', ] diff --git a/backend/src/agents/builtin_tools/browser/__init__.py b/backend/src/agents/builtin_tools/browser/__init__.py new file mode 100644 index 000000000..b3fbecdc8 --- /dev/null +++ b/backend/src/agents/builtin_tools/browser/__init__.py @@ -0,0 +1,5 @@ +"""AgentCore Browser tool package.""" + +from .browse_tool import browse_web + +__all__ = ["browse_web"] diff --git a/backend/src/agents/builtin_tools/browser/browse_tool.py b/backend/src/agents/builtin_tools/browser/browse_tool.py new file mode 100644 index 000000000..ffcdcf545 --- /dev/null +++ b/backend/src/agents/builtin_tools/browser/browse_tool.py @@ -0,0 +1,293 @@ +"""`browse_web` — drive a real browser in the AgentCore Browser sandbox. + +One action per call, against a browser session scoped to the conversation +(see `session_pool`). The model navigates, reads, interacts, and closes. + +Cost posture. Every action's output is capped before it reaches the model, +because a browsing transcript is otherwise the classic unbounded per-turn +payload the cost tenet exists to catch: page text is truncated, evaluated +results are truncated, and a screenshot is only ever taken when the model +explicitly asks — vision tokens are the single most expensive thing this tool +can produce, so it is never automatic. Prefer `extract_text` over `screenshot` +for reading; prefer `evaluate` over both for structured extraction. + +Interaction is done through JS in the page rather than synthesized input +events. That covers clicking, filling and reading for ordinary pages, keeps +the CDP surface small, and is what AWS's own guidance recommends for +extraction. Pages that require true trusted input (drag, some canvas widgets, +a few anti-bot forms) are a known limitation. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from strands import tool +from strands.types.tools import ToolContext + +from .cdp_client import CdpError +from . import session_pool + +logger = logging.getLogger(__name__) + +# Output budgets. Roughly: 8000 chars ≈ 2k tokens of page text per action. +MAX_TEXT_CHARS = int(os.environ.get("BROWSER_MAX_TEXT_CHARS", 8000)) +MAX_EVAL_CHARS = int(os.environ.get("BROWSER_MAX_EVAL_CHARS", 4000)) +MAX_LINKS = int(os.environ.get("BROWSER_MAX_LINKS", 50)) + +_ALLOWED_SCHEMES = ("http", "https") + +# Page text, preferring the main content region over site chrome. +_EXTRACT_TEXT_JS = """ +(() => { + const el = document.querySelector('main') || document.querySelector('article') || document.body; + return el ? el.innerText : ''; +})() +""" + +_EXTRACT_LINKS_JS = """ +(() => { + const seen = new Set(); + const out = []; + for (const a of document.querySelectorAll('a[href]')) { + const href = a.href; + if (!href || seen.has(href)) continue; + if (!href.startsWith('http')) continue; + seen.add(href); + out.push({ text: (a.innerText || '').trim().slice(0, 120), href }); + if (out.length >= %d) break; + } + return out; +})() +""" + + +def _ok(text: str, extra: Optional[list] = None) -> Dict[str, Any]: + content = [{"text": text}] + if extra: + content.extend(extra) + return {"content": content, "status": "success"} + + +def _err(text: str) -> Dict[str, Any]: + return {"content": [{"text": text}], "status": "error"} + + +def _truncate(value: str, limit: int, label: str) -> str: + if len(value) <= limit: + return value + return ( + value[:limit] + + f"\n\n[{label} truncated at {limit} chars of {len(value)}. " + "Use `evaluate` with a selector to extract only what you need.]" + ) + + +def _tool_enabled() -> bool: + """Kill switch. Default on, per house style.""" + return os.environ.get("BROWSER_TOOL_ENABLED", "true").strip().lower() != "false" + + +def _validate_url(url: str) -> Optional[str]: + """Return an error string if the URL is not a safe public target.""" + parsed = urlparse(url) + if parsed.scheme not in _ALLOWED_SCHEMES: + return f"❌ Only http/https URLs are supported (got '{parsed.scheme or 'none'}')." + host = (parsed.hostname or "").lower() + if not host: + return "❌ URL has no host." + # The browser runs in AWS with PUBLIC network mode, so it cannot reach our + # VPC — but blocking the obvious loopback/metadata targets keeps the tool + # honest if the network mode ever changes. + if host in ("localhost", "127.0.0.1", "::1", "169.254.169.254", "metadata.google.internal"): + return "❌ Refusing to browse loopback or instance-metadata addresses." + return None + + +def _js_string(value: str) -> str: + """Embed a Python string as a JS literal.""" + return json.dumps(value) + + +@tool(context=True) +async def browse_web( + action: str, + tool_context: ToolContext, + url: Optional[str] = None, + selector: Optional[str] = None, + text: Optional[str] = None, + script: Optional[str] = None, +) -> Dict[str, Any]: + """Browse the web in a real Chrome browser: navigate pages, read content, fill forms, click. + + Use this when a task needs a live, interactive page — content behind + JavaScript rendering, a multi-step flow, a search you must click through. + For a single static page, `fetch_url_content` is cheaper and faster; reach + for this tool when that one is not enough. + + The browser session persists across calls within the conversation, so you + can navigate then interact then read. Call `close` when finished. + + Args: + action: One of: + `navigate` — go to `url` and return the page title and text. + `extract_text` — return the current page's visible text. + `extract_links` — return the current page's links. + `click` — click the element matching `selector`. + `type` — set `text` into the input matching `selector`. + `evaluate` — run `script` (JavaScript) and return its value. + The best tool for structured extraction, e.g. + `[...document.querySelectorAll('h2')].map(e => e.innerText)`. + `screenshot` — capture the viewport as an image. Expensive in + tokens; only use it when you must SEE the layout. + `live_view` — get a URL where the user can watch the session. + `close` — end the browser session. + url: Target URL for `navigate` (http/https). + selector: CSS selector for `click` and `type`. + text: Text to enter for `type`. + script: JavaScript expression for `evaluate`. + + Returns: + ToolResult with the action's output, truncated to a token budget. + """ + if not _tool_enabled(): + return _err("❌ The browser tool is disabled in this environment.") + + action = (action or "").strip().lower() + agent = getattr(tool_context, "agent", None) + if agent is None: + return _err("❌ Browser tool has no agent context.") + + if action == "close": + stopped = await session_pool.release(agent) + return _ok("✅ Browser session closed." if stopped else "No browser session was open.") + + if action == "navigate": + if not url: + return _err("❌ `navigate` requires `url`.") + invalid = _validate_url(url) + if invalid: + return _err(invalid) + + try: + live = await session_pool.acquire(agent) + except Exception as exc: # noqa: BLE001 - surface the reason, not a traceback + logger.error("browser: could not start session: %s", exc, exc_info=True) + return _err(f"❌ Could not start a browser session: {exc}") + + async with live.lock: + try: + return await _dispatch(live, action, url, selector, text, script, agent) + except CdpError as exc: + return _err(f"❌ {exc}") + except Exception as exc: # noqa: BLE001 + logger.error("browser: action '%s' failed: %s", action, exc, exc_info=True) + return _err(f"❌ Browser action '{action}' failed: {exc}") + + +async def _dispatch( + live: Any, + action: str, + url: Optional[str], + selector: Optional[str], + text: Optional[str], + script: Optional[str], + agent: Any, +) -> Dict[str, Any]: + cdp = live.cdp + + if action == "navigate": + await cdp.navigate(url) # type: ignore[arg-type] + title = await cdp.evaluate("document.title") + current = await cdp.evaluate("location.href") + body = await cdp.evaluate(_EXTRACT_TEXT_JS) or "" + return _ok( + f"✅ Navigated to {current}\nTitle: {title}\n\n" + + _truncate(str(body), MAX_TEXT_CHARS, "Page text") + ) + + if action == "extract_text": + body = await cdp.evaluate(_EXTRACT_TEXT_JS) or "" + return _ok(_truncate(str(body), MAX_TEXT_CHARS, "Page text")) + + if action == "extract_links": + links = await cdp.evaluate(_EXTRACT_LINKS_JS % MAX_LINKS) or [] + if not links: + return _ok("No links found on this page.") + lines = [f"- {item.get('text') or '(no text)'} → {item.get('href')}" for item in links] + return _ok(f"{len(lines)} link(s):\n" + "\n".join(lines)) + + if action == "click": + if not selector: + return _err("❌ `click` requires `selector`.") + clicked = await cdp.evaluate( + f""" + (() => {{ + const el = document.querySelector({_js_string(selector)}); + if (!el) return false; + el.click(); + return true; + }})() + """ + ) + if not clicked: + return _err(f"❌ No element matches selector `{selector}`.") + current = await cdp.evaluate("location.href") + return _ok(f"✅ Clicked `{selector}`. Current URL: {current}") + + if action == "type": + if not selector or text is None: + return _err("❌ `type` requires `selector` and `text`.") + # Set the value and fire the events frameworks listen for, so Angular + # / React state updates rather than silently keeping the old value. + typed = await cdp.evaluate( + f""" + (() => {{ + const el = document.querySelector({_js_string(selector)}); + if (!el) return false; + el.focus(); + el.value = {_js_string(text)}; + el.dispatchEvent(new Event('input', {{ bubbles: true }})); + el.dispatchEvent(new Event('change', {{ bubbles: true }})); + return true; + }})() + """ + ) + if not typed: + return _err(f"❌ No element matches selector `{selector}`.") + return _ok(f"✅ Entered text into `{selector}`.") + + if action == "evaluate": + if not script: + return _err("❌ `evaluate` requires `script`.") + value = await cdp.evaluate(script) + try: + rendered = json.dumps(value, ensure_ascii=False, indent=2) + except (TypeError, ValueError): + rendered = str(value) + return _ok(_truncate(rendered, MAX_EVAL_CHARS, "Result")) + + if action == "screenshot": + encoded = await cdp.screenshot() + if not encoded: + return _err("❌ Screenshot returned no data.") + return _ok( + "✅ Screenshot of the current viewport:", + [{"image": {"format": "png", "source": {"bytes": base64.b64decode(encoded)}}}], + ) + + if action == "live_view": + view_url = await session_pool.live_view_url(agent) + if not view_url: + return _err("❌ No live view available for this session.") + return _ok(f"Watch the browser session here (expires shortly):\n{view_url}") + + return _err( + f"❌ Unknown action '{action}'. Valid actions: navigate, extract_text, " + "extract_links, click, type, evaluate, screenshot, live_view, close." + ) diff --git a/backend/src/agents/builtin_tools/browser/cdp_client.py b/backend/src/agents/builtin_tools/browser/cdp_client.py new file mode 100644 index 000000000..5fabcd912 --- /dev/null +++ b/backend/src/agents/builtin_tools/browser/cdp_client.py @@ -0,0 +1,242 @@ +"""Minimal Chrome DevTools Protocol client for the AgentCore Browser. + +AgentCore's automation endpoint is a SigV4-signed WebSocket that speaks CDP. +`bedrock_agentcore.tools.browser_client.BrowserClient` handles the session +lifecycle and request signing; this module handles the protocol on top of it. + +Why raw CDP instead of Playwright: `websockets` is already in the image, and +the actions a browsing agent needs — navigate, evaluate, extract, screenshot — +are a handful of CDP commands. Playwright would add its bundled Node driver to +the inference-api container for auto-waiting and a selector engine we mostly +don't use, because JS evaluated in the page does the same job. If richer +interaction (frames, file chooser, real input events) is ever needed, this +module is the seam to swap. + +Nothing here is specific to the AgentCore Browser except `connect()`. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Dict, Optional + +from websockets.asyncio.client import connect as ws_connect + +logger = logging.getLogger(__name__) + +# CDP command timeout. Navigation uses its own, longer budget. +_COMMAND_TIMEOUT_SECONDS = 30.0 + +# A screenshot frame can be a few MB; the default websockets frame cap (1 MiB) +# is too small for `Page.captureScreenshot`. +_MAX_FRAME_BYTES = 16 * 1024 * 1024 + + +class CdpError(RuntimeError): + """A CDP command returned an error, or the connection failed.""" + + +class CdpSession: + """One CDP connection, attached to a single page target. + + Commands are multiplexed over the socket by request id. A background + reader resolves futures; events are dropped (we poll instead of + subscribing, which keeps the state machine to one path). + """ + + def __init__(self, websocket: Any) -> None: + self._ws = websocket + self._next_id = 0 + self._pending: Dict[int, asyncio.Future] = {} + self._reader: Optional[asyncio.Task] = None + self._page_session_id: Optional[str] = None + self._closed = False + + # -- lifecycle --------------------------------------------------------- + + @classmethod + async def connect(cls, ws_url: str, headers: Dict[str, str]) -> "CdpSession": + """Open the automation socket and attach to a page target.""" + websocket = await ws_connect( + ws_url, + additional_headers=headers, + max_size=_MAX_FRAME_BYTES, + open_timeout=30, + ) + session = cls(websocket) + session._reader = asyncio.create_task(session._read_loop()) + await session._attach_to_page() + return session + + async def close(self) -> None: + """Close the socket and cancel the reader. Safe to call twice.""" + if self._closed: + return + self._closed = True + if self._reader is not None: + self._reader.cancel() + try: + await self._ws.close() + except Exception: # noqa: BLE001 - teardown is best effort + logger.debug("cdp: websocket close failed", exc_info=True) + for future in self._pending.values(): + if not future.done(): + future.set_exception(CdpError("CDP connection closed")) + self._pending.clear() + + @property + def closed(self) -> bool: + return self._closed + + # -- protocol ---------------------------------------------------------- + + async def _read_loop(self) -> None: + try: + async for raw in self._ws: + try: + message = json.loads(raw) + except (TypeError, ValueError): + continue + message_id = message.get("id") + if message_id is None: + continue # an event; we don't subscribe to any + future = self._pending.pop(message_id, None) + if future is not None and not future.done(): + future.set_result(message) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - surfaced on the next command + logger.info("cdp: read loop ended (%s)", exc) + for future in self._pending.values(): + if not future.done(): + future.set_exception(CdpError(f"CDP connection lost: {exc}")) + self._pending.clear() + + async def command( + self, + method: str, + params: Optional[Dict[str, Any]] = None, + *, + session_scoped: bool = True, + timeout: float = _COMMAND_TIMEOUT_SECONDS, + ) -> Dict[str, Any]: + """Send one CDP command and return its `result`. + + `session_scoped` targets the attached page; pass False for + browser-level commands (`Target.*`). + """ + if self._closed: + raise CdpError("CDP session is closed") + + self._next_id += 1 + message_id = self._next_id + payload: Dict[str, Any] = {"id": message_id, "method": method} + if params: + payload["params"] = params + if session_scoped and self._page_session_id: + payload["sessionId"] = self._page_session_id + + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending[message_id] = future + try: + await self._ws.send(json.dumps(payload)) + message = await asyncio.wait_for(future, timeout=timeout) + except asyncio.TimeoutError as exc: + self._pending.pop(message_id, None) + raise CdpError(f"{method} timed out after {timeout:.0f}s") from exc + finally: + self._pending.pop(message_id, None) + + if "error" in message: + detail = message["error"].get("message", "unknown error") + raise CdpError(f"{method} failed: {detail}") + return message.get("result", {}) + + async def _attach_to_page(self) -> None: + """Find a page target and attach, so later commands address a tab.""" + targets = await self.command("Target.getTargets", session_scoped=False) + pages = [ + t for t in targets.get("targetInfos", []) if t.get("type") == "page" + ] + if not pages: + created = await self.command( + "Target.createTarget", {"url": "about:blank"}, session_scoped=False + ) + target_id = created["targetId"] + else: + target_id = pages[0]["targetId"] + + attached = await self.command( + "Target.attachToTarget", + {"targetId": target_id, "flatten": True}, + session_scoped=False, + ) + self._page_session_id = attached["sessionId"] + await self.command("Page.enable") + await self.command("Runtime.enable") + + # -- page operations --------------------------------------------------- + + async def evaluate(self, expression: str, *, timeout: float = _COMMAND_TIMEOUT_SECONDS) -> Any: + """Evaluate JS in the page and return the value by value. + + Raises `CdpError` if the expression throws — the message carries the + page's own exception text, which is what the model needs to correct + itself. + """ + result = await self.command( + "Runtime.evaluate", + { + "expression": expression, + "returnByValue": True, + "awaitPromise": True, + "userGesture": True, + }, + timeout=timeout, + ) + exception = result.get("exceptionDetails") + if exception: + text = exception.get("exception", {}).get("description") or exception.get("text") + raise CdpError(f"Page threw: {text}") + return result.get("result", {}).get("value") + + async def navigate(self, url: str, *, timeout: float = 45.0) -> None: + """Navigate and wait for the document to finish loading. + + Polls `document.readyState` rather than subscribing to lifecycle + events — one code path, and a page that never fires `load` (long-poll + connections, some SPAs) still returns once the DOM is usable. + """ + result = await self.command("Page.navigate", {"url": url}, timeout=timeout) + if result.get("errorText"): + raise CdpError(f"Navigation failed: {result['errorText']}") + + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + try: + state = await self.evaluate("document.readyState", timeout=10.0) + except CdpError: + state = None # mid-navigation context swap; retry + if state in ("interactive", "complete"): + return + await asyncio.sleep(0.25) + logger.info("cdp: navigation to %s did not settle within %ss", url, timeout) + + async def add_init_script(self, source: str) -> None: + """Install a script that runs before any page script, on every document. + + The WebMCP shim hook: this is the CDP equivalent of Playwright's + `add_init_script`, and it is what would let a page's + `document.modelContext` declarations be collected before the app + bootstraps. Unused today; see `docs/specs/webmcp-host-spa-tools.md`. + """ + await self.command("Page.addScriptToEvaluateOnNewDocument", {"source": source}) + + async def screenshot(self) -> str: + """Capture the viewport as base64 PNG.""" + result = await self.command( + "Page.captureScreenshot", {"format": "png"}, timeout=45.0 + ) + return result.get("data", "") diff --git a/backend/src/agents/builtin_tools/browser/session_pool.py b/backend/src/agents/builtin_tools/browser/session_pool.py new file mode 100644 index 000000000..d2ec789a7 --- /dev/null +++ b/backend/src/agents/builtin_tools/browser/session_pool.py @@ -0,0 +1,254 @@ +"""Browser session lifecycle, keyed per conversation. + +One AgentCore Browser session is reused across the tool calls of a +conversation: starting a session costs seconds and dollars, and a browsing +task is inherently multi-step. + +Where the key lives matters. Per the "one session can be served by more than +one agent" rule in CLAUDE.md, nothing durable may be cached on an agent +instance — so the *identity* of the browser session (its id) is stored on the +Strands `agent.state`, exactly as `app_context_dispatch` stores app context, +while the live socket is a process-local lookup keyed by that id. A second +agent instance for the same conversation reads the same id from state; if the +socket isn't in this process it reconnects to the same remote session rather +than starting a second one. + +`AgentState.get()` deep-copies, so the bag is read-modify-written wholesale +and every value is JSON-serializable. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Tuple + +from .cdp_client import CdpError, CdpSession + +logger = logging.getLogger(__name__) + +STATE_KEY = "browser_tool" +_SESSION_SUBKEY = "session" + +# AgentCore caps session_timeout at 8h; we deliberately stay near the low end. +# An abandoned session bills until its TTL expires, and a browsing task that +# needs more than 15 minutes of wall clock is a task that should be re-scoped. +SESSION_TIMEOUT_SECONDS = int(os.environ.get("BROWSER_SESSION_TIMEOUT_SECONDS", 900)) + +# Local reap: drop sockets unused for this long, and stop the remote session +# with them. Shorter than the remote TTL so we usually release first. +IDLE_REAP_SECONDS = int(os.environ.get("BROWSER_IDLE_REAP_SECONDS", 600)) + +DEFAULT_VIEWPORT = {"width": 1280, "height": 800} + + +@dataclass +class _LiveSession: + """A connected browser session owned by this process.""" + + session_id: str + identifier: str + client: Any # bedrock_agentcore BrowserClient + cdp: CdpSession + last_used: float = field(default_factory=time.monotonic) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +_live: Dict[str, _LiveSession] = {} +_pool_lock = asyncio.Lock() + + +def _browser_identifier() -> str: + """Our custom browser from PlatformStack, else the AWS-managed one.""" + return os.environ.get("BROWSER_ID") or "aws.browser.v1" + + +def _region() -> str: + return os.environ.get("AWS_REGION", "us-west-2") + + +def _read_state(agent: Any) -> Optional[Dict[str, Any]]: + state = getattr(agent, "state", None) + if state is None: + return None + bag = state.get(STATE_KEY) or {} + entry = bag.get(_SESSION_SUBKEY) + return entry if isinstance(entry, dict) else None + + +def _write_state(agent: Any, entry: Optional[Dict[str, Any]]) -> None: + state = getattr(agent, "state", None) + if state is None: + return + bag = dict(state.get(STATE_KEY) or {}) + if entry is None: + bag.pop(_SESSION_SUBKEY, None) + else: + bag[_SESSION_SUBKEY] = entry + try: + state.set(STATE_KEY, bag) + except ValueError: + logger.warning("browser: session state not serializable; not persisted") + + +async def _start_remote_session() -> Tuple[Any, str, str]: + """Start an AgentCore browser session. Returns (client, identifier, id).""" + from bedrock_agentcore.tools.browser_client import BrowserClient + + identifier = _browser_identifier() + client = BrowserClient(region=_region()) + # boto3 is synchronous; keep it off the event loop. + session_id = await asyncio.to_thread( + client.start, + identifier=identifier, + session_timeout_seconds=SESSION_TIMEOUT_SECONDS, + viewport=DEFAULT_VIEWPORT, + ) + logger.info( + "browser: started session %s on %s (ttl=%ss)", + session_id, identifier, SESSION_TIMEOUT_SECONDS, + ) + return client, identifier, session_id + + +async def _connect(client: Any) -> CdpSession: + ws_url, headers = await asyncio.to_thread(client.generate_ws_headers) + return await CdpSession.connect(ws_url, headers) + + +async def _reap_idle() -> None: + """Close sockets unused past the idle window, stopping the remote session.""" + now = time.monotonic() + stale = [ + sid for sid, live in _live.items() + if now - live.last_used > IDLE_REAP_SECONDS + ] + for sid in stale: + live = _live.pop(sid, None) + if live is None: + continue + logger.info("browser: reaping idle session %s", sid) + await _teardown(live) + + +async def _teardown(live: _LiveSession) -> None: + try: + await live.cdp.close() + except Exception: # noqa: BLE001 - teardown is best effort + logger.debug("browser: cdp close failed", exc_info=True) + try: + await asyncio.to_thread(live.client.stop) + except Exception: # noqa: BLE001 + logger.debug("browser: remote stop failed", exc_info=True) + + +async def acquire(agent: Any) -> _LiveSession: + """Return this conversation's live session, starting or reconnecting it. + + Reconnection matters: the state may name a remote session this process + has never seen (a second cached agent, or a container that restarted). + Reconnecting is strictly cheaper than starting a second browser. + """ + async with _pool_lock: + await _reap_idle() + + entry = _read_state(agent) + if entry: + session_id = entry.get("sessionId") + live = _live.get(session_id) if session_id else None + if live is not None and not live.cdp.closed: + live.last_used = time.monotonic() + return live + if session_id and entry.get("identifier"): + reconnected = await _try_reconnect(entry) + if reconnected is not None: + return reconnected + + client, identifier, session_id = await _start_remote_session() + try: + cdp = await _connect(client) + except Exception: + await asyncio.to_thread(client.stop) + raise + + live = _LiveSession( + session_id=session_id, identifier=identifier, client=client, cdp=cdp + ) + _live[session_id] = live + _write_state( + agent, + { + "sessionId": session_id, + "identifier": identifier, + "startedAt": time.time(), + }, + ) + return live + + +async def _try_reconnect(entry: Dict[str, Any]) -> Optional[_LiveSession]: + """Re-attach to a remote session named in state. None if it's gone.""" + from bedrock_agentcore.tools.browser_client import BrowserClient + + session_id = entry["sessionId"] + identifier = entry["identifier"] + client = BrowserClient(region=_region()) + client.identifier = identifier + client.session_id = session_id + try: + cdp = await _connect(client) + except Exception as exc: # noqa: BLE001 - expired/stopped session + logger.info("browser: cannot reconnect to %s (%s)", session_id, exc) + return None + + logger.info("browser: reconnected to session %s", session_id) + live = _LiveSession( + session_id=session_id, identifier=identifier, client=client, cdp=cdp + ) + _live[session_id] = live + return live + + +async def release(agent: Any) -> bool: + """Stop this conversation's browser session. Idempotent.""" + entry = _read_state(agent) + _write_state(agent, None) + if not entry: + return False + session_id = entry.get("sessionId") + live = _live.pop(session_id, None) if session_id else None + if live is not None: + await _teardown(live) + return True + if session_id and entry.get("identifier"): + # Not ours to close locally, but still billing remotely. + from bedrock_agentcore.tools.browser_client import BrowserClient + + client = BrowserClient(region=_region()) + client.identifier = entry["identifier"] + client.session_id = session_id + try: + await asyncio.to_thread(client.stop) + return True + except Exception: # noqa: BLE001 + logger.debug("browser: remote stop failed", exc_info=True) + return False + + +async def live_view_url(agent: Any) -> Optional[str]: + """Pre-signed live-view URL for the running session, if there is one.""" + entry = _read_state(agent) + if not entry: + return None + live = _live.get(entry.get("sessionId", "")) + client = live.client if live else None + if client is None: + return None + try: + return await asyncio.to_thread(client.generate_live_view_url) + except Exception: # noqa: BLE001 + logger.debug("browser: live view url failed", exc_info=True) + return None diff --git a/backend/src/agents/main_agent/core/model_config.py b/backend/src/agents/main_agent/core/model_config.py index 13c4a5cbf..83fcdf01c 100644 --- a/backend/src/agents/main_agent/core/model_config.py +++ b/backend/src/agents/main_agent/core/model_config.py @@ -382,7 +382,7 @@ def to_bedrock_config(self) -> Dict[str, Any]: # allows max 4; nothing else in this codebase adds one, see the # position test in tests/agents/main_agent/core/test_bedrock_cache_points.py): # - # 1. toolConfig tail — cache_tools="default" (_build_tools_cache_point) + # 1. toolConfig tail — CacheConfig(tools_ttl=True) (_build_tools_cache_point) # 2. system tail — SystemContentBlock list built by # AgentFactory.create_agent (the deprecated # cache_prompt config key is NOT used) @@ -402,9 +402,9 @@ def to_bedrock_config(self) -> Dict[str, Any]: # ~28k-token static prefix still reads from cache on those turns. # # For a model whose id Strands doesn't recognize as cache-capable, - # auto strategy logs a warning and no-ops — but cache_tools and a - # system cachePoint are sent unconditionally once configured, so both - # are gated on bedrock_cache_points_supported() (the same predicate + # auto strategy logs a warning and no-ops — but the tools and system + # cachePoints are sent unconditionally once configured, so both are + # gated on bedrock_cache_points_supported() (the same predicate # Strands' auto mode uses). Requires strands-agents>=1.48.0: a # cachePoint trailing a non-PDF `document` attachment is rejected by # Bedrock's Anthropic adapter with "ValidationException ... @@ -415,11 +415,33 @@ def to_bedrock_config(self) -> Dict[str, Any]: # document is the first content block. Cache hits are user-visible in # the cost/context badge the moment this is on. # See: https://github.com/strands-agents/sdk-python/issues/1966 + # tools_ttl replaces the model-level cache_tools key, deprecated in + # strands-agents 1.55.0 (_warn_on_deprecated_cache_tools). The emitted + # block is byte-identical either way, which matters because it is the + # tail of the cached prefix: with cache_config.ttl unset, + # _build_tools_cache_point resolves ttl to None for tools_ttl=True + # exactly as _build_deprecated_cache_tools_point did for + # cache_tools="default", so both emit {"cachePoint": {"type": "default"}} + # with no ttl key. False (not None) on the unsupported branch pins the + # off state explicitly rather than falling back through the deprecated + # key. Since cache_config.ttl stays unset, _apply_system_cache_ttl is + # also a no-op — it only rewrites a TTL-less cache point when one is + # configured. + # + # system_prompt_ttl keeps its 1.55 default of True, which appends a + # system cachePoint via _should_cache_system. That is inert on every + # path here: the guard is `not any("cachePoint" in block ...)`, and + # AgentFactory.create_agent already appends its own whenever + # bedrock_cache_points_supported() — the same predicate, so the two + # can't disagree. It stays on as the safety net for a system prompt + # that reaches Bedrock without going through that factory. if self.caching_enabled: from strands.models import CacheConfig - config["cache_config"] = CacheConfig(strategy="auto") - if self.bedrock_cache_points_supported(): - config["cache_tools"] = "default" + config["cache_config"] = CacheConfig( + strategy="auto", + system_prompt_ttl=True, + tools_ttl=self.bedrock_cache_points_supported(), + ) if self.retry_config: from botocore.config import Config as BotocoreConfig diff --git a/backend/src/agents/main_agent/session/hooks/oauth_consent.py b/backend/src/agents/main_agent/session/hooks/oauth_consent.py index 5783e86e1..7c550cf5e 100644 --- a/backend/src/agents/main_agent/session/hooks/oauth_consent.py +++ b/backend/src/agents/main_agent/session/hooks/oauth_consent.py @@ -23,7 +23,6 @@ import asyncio import inspect import logging -import re from typing import Any, Awaitable, Callable, Optional, Union from strands.hooks import ( @@ -41,66 +40,11 @@ custom_parameters_for, get_agentcore_identity_client, ) +from apis.shared.oauth.auth_failure import looks_like_auth_failure logger = logging.getLogger(__name__) -# Markers that indicate an OAuth-style auth failure in a tool result. -# A false positive triggers an unnecessary OAuth popup — far more -# disruptive than a missed match (which surfaces the underlying error to -# the user). So we err on the side of high-confidence signals only. -# -# Tiers: -# 1. HTTP 401 with negative lookarounds for path segments / adjacent -# digits. Bare "401" in MCP error text is almost always an HTTP -# status code in practice. -# 2. "Unauthorized" only when paired with an HTTP/status/code keyword. -# The bare word fires on prose like "you are not authorized to view -# this calendar" — which is application-level, not OAuth. -# 3. Unambiguous OAuth/token signals stand alone — `invalid_token`, -# `invalid_grant` (refresh-token revocation), Google API's -# `UNAUTHENTICATED` and `invalid authentication credentials`. -# -# We only run this on results whose `status == "error"` -# (see `_looks_like_auth_failure`), so even the broader patterns above -# are gated by an explicit failure signal from the MCP framework. -_AUTH_FAILURE_PATTERN = re.compile( - r"(? bool: - """Heuristic: does this tool result look like an OAuth 401? - - Inspects the result's status and content for one of the markers above. - False positives here just trigger a wasted retry; false negatives - leave the user stuck with a stale token, so we err on the side of - matching. - """ - if not isinstance(tool_result, dict): - return False - if tool_result.get("status") != "error": - return False - for block in tool_result.get("content", []) or []: - if not isinstance(block, dict): - continue - text = block.get("text") or "" - if isinstance(text, str) and _AUTH_FAILURE_PATTERN.search(text): - return True - return False - - # Returns provider_id for a Strands `selected_tool`, or None if the tool # isn't OAuth-gated. Encapsulates the MCPClient -> provider mapping. ProviderLookup = Callable[[Any], Optional[str]] @@ -331,7 +275,7 @@ async def _handle_auth_failure(self, event: AfterToolCallEvent) -> None: if not provider_id: return - if not _looks_like_auth_failure(event.result): + if not looks_like_auth_failure(event.result): return # Avoid both an infinite retry loop within a single tool call and a diff --git a/backend/src/agents/main_agent/tools/tool_catalog.py b/backend/src/agents/main_agent/tools/tool_catalog.py index 587a1387e..a7ab56174 100644 --- a/backend/src/agents/main_agent/tools/tool_catalog.py +++ b/backend/src/agents/main_agent/tools/tool_catalog.py @@ -75,6 +75,15 @@ def to_dict(self) -> dict: icon="calculator", ), + # --- Built-in Tools (Browser) --- + "browse_web": ToolMetadata( + tool_id="browse_web", + name="Web Browser", + description="Browse the web in a real Chrome browser: navigate pages, read JavaScript-rendered content, fill forms, and click through multi-step flows.", + category=ToolCategory.SEARCH, + icon="globe-alt", + ), + # --- Built-in Tools (Code Interpreter) --- "generate_diagram_and_validate": ToolMetadata( tool_id="generate_diagram_and_validate", diff --git a/backend/src/agents/main_agent/voice_agent.py b/backend/src/agents/main_agent/voice_agent.py index 41da05588..69f7067af 100644 --- a/backend/src/agents/main_agent/voice_agent.py +++ b/backend/src/agents/main_agent/voice_agent.py @@ -5,7 +5,7 @@ real-time voice interaction. Shares session history with text ChatAgent for voice-text continuity. -Requires: strands-agents[bidi] extra for BidiAgent and BidiNovaSonicModel. +Requires: strands-agents[bidi] extra for BidiAgent and BedrockNovaSonicModel. Based on the voice agent pattern from: https://github.com/aws-samples/sample-strands-agent-with-agentcore @@ -23,10 +23,18 @@ logger = logging.getLogger(__name__) -# Optional imports — BidiAgent requires the strands bidi extra +# Optional imports — BidiAgent requires the strands bidi extra. +# +# strands-agents 1.55.0 renamed the provider: the module went +# ``models.nova_sonic`` -> ``models.bedrock`` and the class +# ``BidiNovaSonicModel`` -> ``BedrockNovaSonicModel``. That is an ImportError, +# which this block swallows into BIDI_AVAILABLE=False — so a stale import would +# not crash, it would silently turn voice off everywhere with one INFO line. +# Import the name explicitly rather than leaning on the package's lazy +# ``__getattr__``, so a future rename fails loudly here too. try: from strands.experimental.bidi import BidiAgent - from strands.experimental.bidi.models.nova_sonic import BidiNovaSonicModel + from strands.experimental.bidi.models.bedrock import BedrockNovaSonicModel BIDI_AVAILABLE = True except ImportError: BIDI_AVAILABLE = False @@ -45,7 +53,7 @@ class VoiceAgent(BaseAgent): Bidirectional voice agent using AWS Nova Sonic 2. Provides: - - Real-time speech-to-speech via BidiNovaSonicModel + - Real-time speech-to-speech via BedrockNovaSonicModel - Voice-text continuity (loads previous text chat history) - Separate agent_id ("voice") to avoid session state conflicts - Configurable voice, sample rate, and model via environment variables @@ -95,18 +103,21 @@ def _create_agent(self) -> None: EnvVars.NOVA_SONIC_MODEL_ID, Defaults.NOVA_SONIC_MODEL_ID ) - model = BidiNovaSonicModel( + # 1.55.0 flattened the provider's constructor: the audio settings + # moved from provider_config["audio"] to the `audio` kwarg (an + # AudioConfig TypedDict with these same five keys), and the region + # moved from client_config["region"] to `region`. Both are + # keyword-only now. + model = BedrockNovaSonicModel( model_id=model_id, - provider_config={ - "audio": { - "voice": self._voice, - "input_rate": Defaults.NOVA_SONIC_INPUT_RATE, - "output_rate": Defaults.NOVA_SONIC_OUTPUT_RATE, - "channels": 1, - "format": "pcm", - }, + audio={ + "voice": self._voice, + "input_rate": Defaults.NOVA_SONIC_INPUT_RATE, + "output_rate": Defaults.NOVA_SONIC_OUTPUT_RATE, + "channels": 1, + "format": "pcm", }, - client_config={"region": os.environ.get(EnvVars.AWS_REGION, Defaults.AWS_REGION)}, + region=os.environ.get(EnvVars.AWS_REGION, Defaults.AWS_REGION), ) # Build voice-specific system prompt diff --git a/backend/src/apis/app_api/assistants/routes.py b/backend/src/apis/app_api/assistants/routes.py index 279ea7b29..ac28211b9 100644 --- a/backend/src/apis/app_api/assistants/routes.py +++ b/backend/src/apis/app_api/assistants/routes.py @@ -53,7 +53,7 @@ update_share_permission, ) from apis.shared.assistants.kb_access import granted -from apis.shared.assistants.rag_service import augment_prompt_with_context, search_assistant_knowledgebase_with_formatting +from apis.shared.assistants.rag_service import augment_prompt_with_context, resolve_context_cap, search_assistant_knowledgebase_with_formatting logger = logging.getLogger(__name__) @@ -532,8 +532,10 @@ async def test_chat_endpoint(assistant_id: str, request: AssistantTestChatReques access=granted(assistant_id, user_id, permission), ) - # 5. Augment user message with retrieved context - augmented_message = augment_prompt_with_context(user_message=request.message, context_chunks=context_chunks) + # 5. Augment user message with retrieved context (engine-aware cap: + # managed 8,000, legacy 2,000 — Requirement 3.2 / HANDOFF §5.40). + cap = resolve_context_cap(assistant_id) + augmented_message = augment_prompt_with_context(user_message=request.message, context_chunks=context_chunks, max_context_length=cap) # 6. Create agent with assistant's instructions as system prompt agent = await get_agent( diff --git a/backend/src/apis/app_api/fine_tuning/job_models.py b/backend/src/apis/app_api/fine_tuning/job_models.py index 6620bc2ab..97a693de7 100644 --- a/backend/src/apis/app_api/fine_tuning/job_models.py +++ b/backend/src/apis/app_api/fine_tuning/job_models.py @@ -39,6 +39,7 @@ def _hyperparameters(task_type: str, **overrides: str) -> Dict[str, str]: _TEXT = task_types.TEXT_CLASSIFICATION _IMAGE = task_types.IMAGE_CLASSIFICATION _IMAGE_TEXT = task_types.IMAGE_TEXT_CLASSIFICATION +_VLM = task_types.IMAGE_TEXT_TO_TEXT AVAILABLE_MODELS: List[AvailableModel] = [ @@ -229,6 +230,81 @@ def _hyperparameters(task_type: str, **overrides: str) -> Dict[str, str]: default_instance_type="ml.g6.xlarge", default_hyperparameters=_hyperparameters(_IMAGE_TEXT), ), + # --------------------------------------------------------------- + # Image + text to text (generative VLM) + # + # These are LoRA-adapted, not fully fine-tuned: a full fine-tune of even + # the smallest entry here needs more optimiser memory than the largest + # instance we offer. Every entry must ship a chat template, because the + # trainer formats turns with it rather than inventing a dialogue format + # the checkpoint has never seen. + # + # context_length is the sequence budget INCLUDING expanded image tokens. + # LLaVA-1.5 emits 576 per image; the 1.6/NeXT models tile at higher + # resolution and emit up to 2880, which is why they get a larger budget. + # --------------------------------------------------------------- + AvailableModel( + model_id="smolvlm-instruct", + model_name="SmolVLM Instruct", + huggingface_model_id="HuggingFaceTB/SmolVLM-Instruct", + description="2.2B parameter vision-language model from HuggingFace, small enough to iterate on quickly and the cheapest way to validate a dataset", + task_type=_VLM, + default_instance_type="ml.g6e.xlarge", + default_hyperparameters=_hyperparameters( + _VLM, + # Small enough to train in bf16, which is faster than 4-bit and + # avoids the dequantisation overhead entirely. + load_in_4bit="false", + per_device_train_batch_size="2", + gradient_accumulation_steps="4", + ), + ), + AvailableModel( + model_id="llava-1.5-7b", + model_name="LLaVA 1.5 7B", + huggingface_model_id="llava-hf/llava-1.5-7b-hf", + description="7B parameter vision-language model, the standard baseline for visual instruction tuning at a fixed 576 image tokens", + task_type=_VLM, + default_instance_type="ml.g6e.xlarge", + default_hyperparameters=_hyperparameters(_VLM), + ), + AvailableModel( + model_id="llava-1.6-mistral-7b", + model_name="LLaVA 1.6 Mistral 7B", + huggingface_model_id="llava-hf/llava-v1.6-mistral-7b-hf", + description="7.6B parameter LLaVA-NeXT on Mistral, higher-resolution tiling than 1.5 and correspondingly slower per record", + task_type=_VLM, + default_instance_type="ml.g6e.xlarge", + default_hyperparameters=_hyperparameters(_VLM, context_length="2048"), + ), + AvailableModel( + model_id="qwen25-vl-7b-instruct", + model_name="Qwen2.5-VL 7B Instruct", + huggingface_model_id="Qwen/Qwen2.5-VL-7B-Instruct", + description="8.3B parameter vision-language model from Alibaba with dynamic resolution, strong on documents, charts and OCR-heavy images", + task_type=_VLM, + default_instance_type="ml.g6e.xlarge", + default_hyperparameters=_hyperparameters(_VLM, context_length="2048"), + ), + AvailableModel( + model_id="llava-1.6-34b", + model_name="LLaVA 1.6 34B", + huggingface_model_id="llava-hf/llava-v1.6-34b-hf", + description="34.8B parameter LLaVA-NeXT on Yi-34B, the most capable option and by far the slowest — expect multi-hour runs and budget accordingly", + task_type=_VLM, + # One L40S still holds the 4-bit weights (~18GB), so the cheapest + # instance that fits is a single-GPU one. The larger size is for host + # RAM, not VRAM: the base arrives as 15 shards that are quantised on + # the way in. + default_instance_type="ml.g6e.4xlarge", + default_hyperparameters=_hyperparameters( + _VLM, + context_length="2048", + gradient_accumulation_steps="16", + lora_r="8", + lora_alpha="16", + ), + ), ] MODEL_CATALOG: Dict[str, AvailableModel] = {m.model_id: m for m in AVAILABLE_MODELS} @@ -311,3 +387,7 @@ class TaskTypeResponse(BaseModel): requires_archive: bool inference_upload_extensions: List[str] default_instance_type: str + #: True when the task emits free text rather than class probabilities. + #: Defaulted so a client built against the classification-only shape keeps + #: deserialising this response. + is_generative: bool = False diff --git a/backend/src/apis/app_api/fine_tuning/routes.py b/backend/src/apis/app_api/fine_tuning/routes.py index f634fb866..14605f300 100644 --- a/backend/src/apis/app_api/fine_tuning/routes.py +++ b/backend/src/apis/app_api/fine_tuning/routes.py @@ -124,6 +124,7 @@ async def list_task_types( requires_archive=spec.requires_archive, inference_upload_extensions=list(spec.inference_upload_extensions), default_instance_type=spec.default_instance_type, + is_generative=spec.is_generative, ) for spec in ( task_types.get_task_spec(t) for t in task_types.TASK_TYPES @@ -588,8 +589,9 @@ async def create_job( hyperparameters["job_pk"] = f"USER#{user.user_id}" hyperparameters["job_sk"] = f"JOB#{job_id}" - # Ensure training scripts are uploaded and get the S3 URI - scripts_s3_uri = script_service.ensure_scripts_uploaded() + # Ensure training scripts are uploaded and get the S3 URI. The archive is + # per DLC family: the families install different dependency sets. + scripts_s3_uri = script_service.ensure_scripts_uploaded(spec.task_type) # S3 paths output_s3_prefix = s3_service.get_output_s3_prefix(user.user_id, job_id) diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py index c647424d9..ecbb666b3 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py @@ -23,11 +23,13 @@ from .. import task_types from . import task_image_classification from . import task_image_text_classification + from . import task_image_text_to_text from . import task_text_classification except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC import task_types # type: ignore import task_image_classification # type: ignore import task_image_text_classification # type: ignore + import task_image_text_to_text # type: ignore import task_text_classification # type: ignore logger = logging.getLogger(__name__) @@ -40,6 +42,7 @@ task_types.TEXT_CLASSIFICATION: task_text_classification, task_types.IMAGE_CLASSIFICATION: task_image_classification, task_types.IMAGE_TEXT_CLASSIFICATION: task_image_text_classification, + task_types.IMAGE_TEXT_TO_TEXT: task_image_text_to_text, } #: Task type of the artifact currently loaded, remembered at module scope. @@ -151,21 +154,11 @@ def _escape_csv(value): return '"' + str(value).replace('"', '""') + '"' -def output_fn(prediction, accept="text/csv"): - """Format predictions as CSV with one probability column per class. - - Output format: - id,prob_label1,prob_label2,... - "example.jpg",0.850000,0.150000 - - The first column is whatever identifies a record for the task: the input - text for text classification, the archive-relative image path for the - image tasks. - """ +def _classification_rows(prediction, identifier_column): + """CSV rows for a task that emits one probability column per class.""" identifiers = prediction["identifiers"] probabilities = prediction["probabilities"] labels = prediction["labels"] - identifier_column = prediction.get("identifier_column", "id") header = identifier_column + "," + ",".join( f"prob_{_sanitize_label(label)}" for label in labels @@ -181,5 +174,56 @@ def output_fn(prediction, accept="text/csv"): else: values = ",".join("0.000000" for _ in labels) rows.append(f"{_escape_csv(identifier)},{values}") + return rows + + +def _generative_rows(prediction, identifier_column): + """CSV rows for a task that emits free text. + + Still CSV, and still one row per input record, so the result file + downloads and opens exactly like a classification result. Generated text + routinely contains commas, quotes and newlines; ``_escape_csv`` quotes and + doubles them, which is valid CSV for all three. + """ + identifiers = prediction["identifiers"] + prompts = prediction.get("prompts", []) + generations = prediction["generations"] + + rows = [f"{identifier_column},prompt,output"] + for index, identifier in enumerate(identifiers): + prompt = prompts[index] if index < len(prompts) else "" + generation = generations[index] if index < len(generations) else "" + rows.append( + f"{_escape_csv(identifier)},{_escape_csv(prompt)},{_escape_csv(generation)}" + ) + return rows + + +def output_fn(prediction, accept="text/csv"): + """Format predictions as CSV. + + Two shapes, chosen by what the task produced rather than by a flag the + caller has to pass: + + Classification — an identifier followed by one probability per class:: + + id,prob_label1,prob_label2 + "example.jpg",0.850000,0.150000 + + Generative — an identifier, the prompt, and the generated text:: + + image,prompt,output + "cat.jpg","What is this?","A tabby cat sitting on a windowsill." + + The first column is whatever identifies a record for the task: the input + text for text classification, the archive-relative image path for every + image task. + """ + identifier_column = prediction.get("identifier_column", "id") + + if "generations" in prediction: + rows = _generative_rows(prediction, identifier_column) + else: + rows = _classification_rows(prediction, identifier_column) return "\n".join(rows) diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/requirements-vlm.txt b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/requirements-vlm.txt new file mode 100644 index 000000000..f34145057 --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/requirements-vlm.txt @@ -0,0 +1,15 @@ +# Packaged AS requirements.txt for generative-VLM jobs only. +# +# These cannot go in the shared requirements.txt: bitsandbytes requires +# torch>=2.4 and the text DLC is torch 2.1, so one shared file would fail to +# install on the container every existing text job runs in. The packaging +# service picks the file by DLC family — see +# script_packaging_service.REQUIREMENTS_BY_FAMILY. +# +# torch, transformers, datasets, evaluate and accelerate are supplied by the +# DLC and are deliberately unpinned here; pinning them would fight the image. +pandas +scikit-learn +pillow==12.3.0 +peft==0.20.0 +bitsandbytes==0.50.2 diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_common.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_common.py index ccc8106fe..c11306f10 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_common.py +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_common.py @@ -505,6 +505,7 @@ def build_callbacks(args): "task_text_classification.py", "task_image_classification.py", "task_image_text_classification.py", + "task_image_text_to_text.py", ) diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_text_to_text.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_text_to_text.py new file mode 100644 index 000000000..e09249a43 --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_text_to_text.py @@ -0,0 +1,586 @@ +"""Generative vision-language fine-tuning: (image, prompt) -> free text. + +The three classification tasks all end in a softmax over a fixed class list. +This one does not: the model keeps its language-modelling head and learns to +*write* the response, so the dataset has no label column and the result file +carries a text column instead of one probability column per class. + +**Why LoRA and not a full fine-tune.** A generative VLM worth using starts +around 7B parameters and the catalog goes to 34B. A full fine-tune needs +roughly 16 bytes per parameter once gradients and the AdamW moments are +resident — ~550GB for a 34B model, against the 384GB on the largest instance +we offer. Quantising the frozen base to 4-bit and training only low-rank +adapters brings the same model to ~18GB of weights, which fits one 48GB card +and leaves the activations room to breathe. The trade is that the artifact is +an *adapter*, not a model: it is a few hundred MB rather than 69GB, and +inference reloads the base from the Hub and applies the adapter on top. + +**Prompt masking.** Loss is computed on the response only. Training on the +prompt as well is the usual accident here: the model spends most of its +gradient budget learning to reproduce questions it will always be given. The +prompt length is measured by rendering each record twice — once with the +generation prompt and no answer, once complete — and masking that many leading +positions. +""" + +import json +import logging +import os + +try: # package context: unit tests and the app-api container + from . import task_common +except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC + import task_common # type: ignore + +logger = logging.getLogger(__name__) + +#: Written next to the adapter so inference can rebuild base + adapter without +#: being told which base was used. +ADAPTER_CONFIG_FILENAME = "vlm_adapter.json" + +#: Ignored index for positions that must not contribute to the loss. +LABEL_IGNORE_INDEX = -100 + +def prompt_mask_limit(prompt_length, sequence_length): + """How many leading positions of a row to exclude from the loss. + + Truncation can cut a long prompt so short that nothing of the response + survives. Masking the whole row makes its loss NaN, which propagates + through the batch average and destroys the step — so the last position is + always left scoreable and such an example contributes almost nothing + instead of poisoning the batch. + """ + return max(0, min(int(prompt_length), int(sequence_length) - 1)) + + +#: Generation runs one record at a time. Batched generation needs left +#: padding and a per-architecture agreement about where image placeholders may +#: sit relative to the pad run; getting it subtly wrong produces fluent +#: garbage rather than an error, which is the worst failure mode for a result +#: file a researcher will treat as data. +GENERATION_BATCH_SIZE = 1 + + +# ========================================================================= +# Chat rendering +# ========================================================================= + +def build_messages(prompt, response=None): + """Return chat messages for one record. + + ``response=None`` yields the prompt half only, which is what both the + generation path and the prompt-length measurement need. + """ + messages = [ + { + "role": "user", + "content": [{"type": "image"}, {"type": "text", "text": str(prompt)}], + } + ] + if response is not None: + messages.append( + { + "role": "assistant", + "content": [{"type": "text", "text": str(response)}], + } + ) + return messages + + +def render_chat(processor, messages, add_generation_prompt): + """Render messages to a string using the processor's chat template. + + Every model in the catalog ships a chat template. A checkpoint without + one would otherwise be rendered with an invented format that does not + match its pre-training, which trains the model to answer in a dialogue + shape it has never seen — so this raises instead of guessing. + """ + template = getattr(processor, "chat_template", None) or getattr( + getattr(processor, "tokenizer", None), "chat_template", None + ) + if not template: + raise ValueError( + "This checkpoint ships no chat template, so there is no faithful " + "way to format an image/prompt/response turn for it. Choose a " + "instruction-tuned vision-language model (the catalog entries all " + "carry one)." + ) + return processor.apply_chat_template( + messages, tokenize=False, add_generation_prompt=add_generation_prompt + ) + + +# ========================================================================= +# Model loading +# ========================================================================= + +def build_quantization_config(load_in_4bit): + """Return a BitsAndBytesConfig for 4-bit NF4, or None to load in bf16.""" + if not load_in_4bit: + return None + + import torch + from transformers import BitsAndBytesConfig + + return BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_quant_type="nf4", + bnb_4bit_use_double_quant=True, + bnb_4bit_compute_dtype=torch.bfloat16, + ) + + +def load_base_model(model_name_or_path, load_in_4bit): + """Load a vision-language model for training or inference. + + ``device_map="auto"`` shards across whatever GPUs the instance has, which + is what lets a 34B adapter run on a multi-GPU instance without any + distributed launcher. + """ + import torch + from transformers import AutoModelForImageTextToText + + quantization_config = build_quantization_config(load_in_4bit) + kwargs = { + "torch_dtype": torch.bfloat16, + "device_map": "auto", + } + if quantization_config is not None: + kwargs["quantization_config"] = quantization_config + + logger.info( + f"Loading {model_name_or_path} " + f"({'4-bit NF4' if load_in_4bit else 'bfloat16'})" + ) + return AutoModelForImageTextToText.from_pretrained(model_name_or_path, **kwargs) + + +def resolve_image_token_ids(processor, model_config): + """Best-effort set of token ids standing in for image patches. + + These positions carry no text the model should be scored on, so they are + masked out of the labels. The attribute moved between transformers + versions and differs by architecture, so every known spelling is tried and + an empty set is an acceptable answer: the pad and prompt masking below + still apply, and a handful of unmasked placeholder positions degrades the + loss slightly rather than breaking it. + """ + ids = set() + + for attribute in ("image_token_index", "image_token_id"): + value = getattr(model_config, attribute, None) + if isinstance(value, int): + ids.add(value) + + tokenizer = getattr(processor, "tokenizer", None) + token = getattr(processor, "image_token", None) + if tokenizer is not None and isinstance(token, str) and token: + try: + resolved = tokenizer.convert_tokens_to_ids(token) + if isinstance(resolved, int) and resolved >= 0: + ids.add(resolved) + except Exception: # pragma: no cover - tokenizer without the token + pass + + return ids + + +# ========================================================================= +# Collation +# ========================================================================= + +def build_collator(processor, spec, context_length, image_token_ids=()): + """Return a collate_fn producing model inputs with response-only labels.""" + import torch + + try: + from . import task_image_classification + except ImportError: # pragma: no cover - flat sourcedir + import task_image_classification # type: ignore + + image_token_ids = set(image_token_ids) + tokenizer = getattr(processor, "tokenizer", None) + pad_token_id = getattr(tokenizer, "pad_token_id", None) + + def _process(images, texts): + return processor( + images=images, + text=texts, + return_tensors="pt", + padding=True, + truncation=True, + max_length=context_length, + ) + + def collate(features): + images = [ + task_image_classification.load_image(feature[spec.image_column]) + for feature in features + ] + prompts = [feature[spec.text_column] for feature in features] + responses = [feature[spec.response_column] for feature in features] + + full_texts = [ + render_chat(processor, build_messages(p, r), add_generation_prompt=False) + for p, r in zip(prompts, responses) + ] + batch = _process(images, full_texts) + + # Measure the prompt half in the same tokenisation the full render + # used, so the count is a true prefix length rather than an estimate. + prompt_texts = [ + render_chat(processor, build_messages(p), add_generation_prompt=True) + for p in prompts + ] + prompt_batch = _process(images, prompt_texts) + prompt_lengths = prompt_batch["attention_mask"].sum(dim=1).tolist() + + labels = batch["input_ids"].clone() + if pad_token_id is not None: + labels[labels == pad_token_id] = LABEL_IGNORE_INDEX + if "attention_mask" in batch: + labels[batch["attention_mask"] == 0] = LABEL_IGNORE_INDEX + for token_id in image_token_ids: + labels[labels == token_id] = LABEL_IGNORE_INDEX + + for row, prompt_length in enumerate(prompt_lengths): + limit = prompt_mask_limit(prompt_length, labels.shape[1]) + if limit > 0: + labels[row, :limit] = LABEL_IGNORE_INDEX + + batch["labels"] = labels + return batch + + return collate + + +# ========================================================================= +# Training +# ========================================================================= + +def train(args, spec): + """LoRA fine-tune a generative vision-language model.""" + import torch + from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training + from transformers import AutoProcessor, Trainer + + train_channel = os.environ.get("SM_CHANNEL_TRAIN", "/opt/ml/input/data/train") + model_dir = os.environ.get("SM_MODEL_DIR", "/opt/ml/model") + + frame, _ = task_common.prepare_dataset(train_channel, spec) + + processor = AutoProcessor.from_pretrained(args.model_name_or_path) + tokenizer = getattr(processor, "tokenizer", None) + if tokenizer is not None and tokenizer.pad_token_id is None: + # Several VLM decoders ship no pad token. Padding with EOS is safe + # because every padded position is masked out of both the attention + # mask and the labels. + tokenizer.pad_token = tokenizer.eos_token + if tokenizer is not None: + tokenizer.padding_side = "right" + + model = load_base_model(args.model_name_or_path, args.load_in_4bit) + + max_ctx = task_common.resolve_max_context_length(model.config, tokenizer) + effective_context = ( + min(args.context_length, max_ctx) if max_ctx else args.context_length + ) + logger.info( + f"Context length: requested={args.context_length}, " + f"effective={effective_context}" + f"{' (capped)' if max_ctx and args.context_length > max_ctx else ''}" + ) + + if args.load_in_4bit: + model = prepare_model_for_kbit_training( + model, use_gradient_checkpointing=True + ) + # Gradient checkpointing recomputes activations instead of storing them, + # which is what makes a multi-thousand-token image sequence fit at all. + # With a frozen, quantised base no input tensor requires grad, so the + # recomputation graph would be empty without this. + model.gradient_checkpointing_enable() + if hasattr(model, "enable_input_require_grads"): + model.enable_input_require_grads() + + # peft accepts either the "all-linear" sentinel or an explicit list; a + # comma-separated hyperparameter is the only way to spell a list through + # SageMaker, which passes every value as a string. + raw_targets = (args.lora_target_modules or "").strip() + if not raw_targets: + target_modules = "all-linear" + elif "," in raw_targets: + target_modules = [t.strip() for t in raw_targets.split(",") if t.strip()] + else: + target_modules = raw_targets + lora_config = LoraConfig( + r=args.lora_r, + lora_alpha=args.lora_alpha, + lora_dropout=args.lora_dropout, + bias="none", + task_type="CAUSAL_LM", + target_modules=target_modules, + ) + model = get_peft_model(model, lora_config) + trainable, total = model.get_nb_trainable_parameters() + logger.info( + f"LoRA: r={args.lora_r}, alpha={args.lora_alpha}, " + f"targets={target_modules}, trainable={trainable:,}/{total:,} " + f"({100 * trainable / total:.3f}%)" + ) + + train_dataset, eval_dataset = task_common.split_frame( + frame, args.split_ratio, args.seed + ) + + image_token_ids = resolve_image_token_ids(processor, model.config) + logger.info(f"Masking image placeholder token ids: {sorted(image_token_ids)}") + + training_args = task_common.build_training_arguments( + output_dir="/opt/ml/checkpoints", + learning_rate=args.learning_rate, + num_train_epochs=args.epochs, + per_device_train_batch_size=args.per_device_train_batch_size, + gradient_accumulation_steps=args.gradient_accumulation_steps, + weight_decay=args.weight_decay, + eval_strategy="epoch", + save_strategy="no", + logging_dir="/opt/ml/output/tensorboard", + remove_unused_columns=False, + label_names=["labels"], + bf16=torch.cuda.is_available(), + gradient_checkpointing=True, + # Paged optimiser states survive the memory spikes a long image + # sequence causes; it is a bitsandbytes optimiser, so it is only + # available on the quantised path. + optim="paged_adamw_8bit" if args.load_in_4bit else "adamw_torch", + ) + + collator = build_collator(processor, spec, effective_context, image_token_ids) + check_collation(collator, train_dataset) + + trainer = Trainer( + model=model, + args=training_args, + train_dataset=train_dataset, + eval_dataset=eval_dataset, + callbacks=task_common.build_callbacks(args), + data_collator=collator, + ) + + logger.info( + f"Starting fine-tuning: task={spec.task_type}, " + f"model={args.model_name_or_path}, epochs={args.epochs}, " + f"batch_size={args.per_device_train_batch_size} x " + f"{args.gradient_accumulation_steps} accumulation" + ) + trainer.train() + + metrics = trainer.evaluate() + logger.info(f"Final evaluation: loss={metrics.get('eval_loss', 'N/A')}") + + # Only the adapter is saved. The base stays on the Hub and is named in + # the sidecar so model_fn can rebuild the pair. + model.save_pretrained(model_dir) + processor.save_pretrained(model_dir) + save_adapter_config(model_dir, args) + logger.info(f"Saved LoRA adapter to {model_dir}") + + return metrics + + +def check_collation(collator, dataset, sample_size=2): + """Collate a couple of records before the Trainer starts. + + The expensive failure here is a processor/template mismatch — most often + truncation cutting into the run of image placeholder tokens, which makes + the count of placeholders disagree with the number of image features and + fails inside the first forward pass. Without this the job has already + downloaded tens of GB of weights and provisioned a GPU before finding out. + + Raising early costs one CPU-side batch and turns a mid-run stack trace + into a message naming the fix. + """ + if len(dataset) == 0: # pragma: no cover - prepare_dataset rejects this + return + + sample = [dataset[i] for i in range(min(sample_size, len(dataset)))] + try: + collator(sample) + except Exception as error: + raise ValueError( + f"Could not build a training batch from this dataset and model: " + f"{error}. The usual cause is a context length too short for the " + f"model's image tokens — a high-resolution VLM can spend a " + f"thousand or more tokens on the image alone, before your prompt. " + f"Raise context_length, or choose a model that tiles less " + f"aggressively." + ) from error + logger.info(f"Collation check passed on {len(sample)} record(s)") + + +def save_adapter_config(model_dir, args): + """Record what the adapter was trained against, for the inference side.""" + os.makedirs(model_dir, exist_ok=True) + payload = { + "base_model_id": args.model_name_or_path, + "load_in_4bit": bool(args.load_in_4bit), + "max_new_tokens": int(args.max_new_tokens), + } + path = os.path.join(model_dir, ADAPTER_CONFIG_FILENAME) + with open(path, "w") as handle: + json.dump(payload, handle, indent=2) + return path + + +# ========================================================================= +# Inference +# ========================================================================= + +def model_fn(model_dir): + """Rebuild base + adapter. The inverse of what ``train`` saved.""" + import torch + from peft import PeftModel + from transformers import AutoProcessor + + with open(os.path.join(model_dir, ADAPTER_CONFIG_FILENAME)) as handle: + adapter_config = json.load(handle) + + processor = AutoProcessor.from_pretrained(model_dir) + tokenizer = getattr(processor, "tokenizer", None) + if tokenizer is not None: + if tokenizer.pad_token_id is None: + tokenizer.pad_token = tokenizer.eos_token + # Generation continues from the right-hand edge, so any padding has to + # sit on the left or the model continues from pad tokens. + tokenizer.padding_side = "left" + + base = load_base_model( + adapter_config["base_model_id"], adapter_config.get("load_in_4bit", True) + ) + model = PeftModel.from_pretrained(base, model_dir) + model.eval() + + device = next(model.parameters()).device + logger.info( + f"Loaded LoRA adapter from {model_dir} over " + f"{adapter_config['base_model_id']} on {device}" + ) + return { + "model": model, + "processor": processor, + "device": device, + "max_new_tokens": adapter_config.get("max_new_tokens", 256), + } + + +def input_fn(request_body, content_type, spec): + """Unpack a .zip holding a manifest plus images into records. + + The manifest needs ``image`` and ``prompt``; ``response`` is the training + target and is not required at inference. + """ + import tempfile + + if not isinstance(request_body, (bytes, bytearray)): + raise ValueError( + f"Expected archive bytes for {spec.task_type}, got " + f"{type(request_body).__name__}" + ) + + work_dir = tempfile.mkdtemp(prefix="inference-vlm-") + archive_path = os.path.join(work_dir, "input.zip") + with open(archive_path, "wb") as handle: + handle.write(bytes(request_body)) + + root = task_common.extract_archive(archive_path, os.path.join(work_dir, "extracted")) + manifest_path = task_common.find_file_in_dir( + root, spec.manifest_extensions, "manifest" + ) + + import pandas as pd + + reader_name, reader_kwargs = task_common.resolve_dataset_reader(manifest_path) + frame = getattr(pd, reader_name)(manifest_path, **reader_kwargs) + + missing = [ + c for c in (spec.image_column, spec.text_column) if c not in frame.columns + ] + if missing: + raise ValueError( + f"Inference manifest is missing required column(s): " + f"{', '.join(missing)}." + ) + + records = [] + for _index, row in frame.iterrows(): + relative = str(row[spec.image_column]).strip() + records.append( + { + spec.image_column: task_common.resolve_image_path(root, relative), + spec.text_column: str(row[spec.text_column]), + "identifier": relative, + } + ) + + if not records: + raise ValueError("Inference manifest contains no records.") + + logger.info(f"Unpacked {len(records)} image/prompt pairs for inference") + return records + + +def predict_fn(records, loaded, spec): + """Generate a response for each record.""" + import torch + + try: + from . import task_image_classification + except ImportError: # pragma: no cover - flat sourcedir + import task_image_classification # type: ignore + + model, processor = loaded["model"], loaded["processor"] + device, max_new_tokens = loaded["device"], loaded["max_new_tokens"] + + if not records: + return {"identifiers": [], "prompts": [], "generations": []} + + generations = [] + with torch.no_grad(): + for start in range(0, len(records), GENERATION_BATCH_SIZE): + chunk = records[start : start + GENERATION_BATCH_SIZE] + images = [ + task_image_classification.load_image(record[spec.image_column]) + for record in chunk + ] + texts = [ + render_chat( + processor, + build_messages(record[spec.text_column]), + add_generation_prompt=True, + ) + for record in chunk + ] + batch = processor( + images=images, text=texts, return_tensors="pt", padding=True + ).to(device) + + output_ids = model.generate( + **batch, max_new_tokens=max_new_tokens, do_sample=False + ) + # generate() returns the prompt followed by the continuation, so + # slice the prompt off rather than decoding it back to the user. + prompt_length = batch["input_ids"].shape[1] + for row in output_ids: + generations.append( + processor.decode( + row[prompt_length:], skip_special_tokens=True + ).strip() + ) + + logger.info(f"Generated {len(generations)} responses") + return { + "identifiers": [record["identifier"] for record in records], + "prompts": [record[spec.text_column] for record in records], + "generations": generations, + } diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py index 3a2d47613..578b62251 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py @@ -26,12 +26,14 @@ from . import task_common from . import task_image_classification from . import task_image_text_classification + from . import task_image_text_to_text from . import task_text_classification except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC import task_types # type: ignore import task_common # type: ignore import task_image_classification # type: ignore import task_image_text_classification # type: ignore + import task_image_text_to_text # type: ignore import task_text_classification # type: ignore logger = logging.getLogger(__name__) @@ -48,9 +50,21 @@ task_types.TEXT_CLASSIFICATION: task_text_classification, task_types.IMAGE_CLASSIFICATION: task_image_classification, task_types.IMAGE_TEXT_CLASSIFICATION: task_image_text_classification, + task_types.IMAGE_TEXT_TO_TEXT: task_image_text_to_text, } +def str2bool(value): + """Parse a boolean hyperparameter. + + SageMaker passes every hyperparameter as a string, so ``bool("false")`` — + which is True — is the trap this exists to avoid. + """ + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("1", "true", "yes", "on") + + def resolve_task_module(task_type): """Return the module implementing ``task_type``. @@ -88,6 +102,16 @@ def parse_args(argv=None): parser.add_argument("--seed", type=int, default=42) parser.add_argument("--context_length", type=int, default=512) parser.add_argument("--image_size", type=int, default=224) + parser.add_argument("--gradient_accumulation_steps", type=int, default=1) + + # Generative VLM (LoRA) hyperparameters. Ignored by the classification + # tasks, which train every weight of a much smaller model. + parser.add_argument("--load_in_4bit", type=str2bool, default=True) + parser.add_argument("--lora_r", type=int, default=16) + parser.add_argument("--lora_alpha", type=int, default=32) + parser.add_argument("--lora_dropout", type=float, default=0.05) + parser.add_argument("--lora_target_modules", type=str, default="") + parser.add_argument("--max_new_tokens", type=int, default=256) # DynamoDB progress reporting parser.add_argument("--dynamodb_table_name", type=str, default="") diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_service.py b/backend/src/apis/app_api/fine_tuning/sagemaker_service.py index 7ee0fc65a..236e5c6b1 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_service.py +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_service.py @@ -31,11 +31,19 @@ _TRAINING_IMAGE_TAGS = { task_types.DLC_FAMILY_TEXT: "huggingface-pytorch-training:2.1.0-transformers4.36.0-gpu-py310-cu121-ubuntu20.04", task_types.DLC_FAMILY_VISION: "huggingface-pytorch-training:2.8.0-transformers4.56.2-gpu-py312-cu129-ubuntu22.04", + # Generative VLMs run the same container as the vision tasks — torch 2.8 + # satisfies bitsandbytes' torch>=2.4 floor and transformers 4.56 knows + # every architecture in the catalog. The family exists to select the + # dependency set (peft + bitsandbytes), not a different image; keeping it + # separate means a future VLM-only image bump cannot re-baseline the + # image-classification jobs. + task_types.DLC_FAMILY_VLM: "huggingface-pytorch-training:2.8.0-transformers4.56.2-gpu-py312-cu129-ubuntu22.04", } _INFERENCE_IMAGE_TAGS = { task_types.DLC_FAMILY_TEXT: "huggingface-pytorch-inference:2.1.0-transformers4.37.0-gpu-py310-cu118-ubuntu20.04", task_types.DLC_FAMILY_VISION: "huggingface-pytorch-inference:2.6.0-transformers4.51.3-gpu-py312-cu124-ubuntu22.04", + task_types.DLC_FAMILY_VLM: "huggingface-pytorch-inference:2.6.0-transformers4.51.3-gpu-py312-cu124-ubuntu22.04", } # The AWS-owned account that publishes Deep Learning Containers. Same in every diff --git a/backend/src/apis/app_api/fine_tuning/script_packaging_service.py b/backend/src/apis/app_api/fine_tuning/script_packaging_service.py index f03a08042..d48441952 100644 --- a/backend/src/apis/app_api/fine_tuning/script_packaging_service.py +++ b/backend/src/apis/app_api/fine_tuning/script_packaging_service.py @@ -10,6 +10,8 @@ import boto3 from botocore.exceptions import ClientError +from . import task_types + logger = logging.getLogger(__name__) # Directory containing the SageMaker scripts (relative to this module) @@ -26,18 +28,42 @@ SCRIPT_FILES = [ "train.py", "inference.py", - "requirements.txt", "task_common.py", "task_text_classification.py", "task_image_classification.py", "task_image_text_classification.py", + "task_image_text_to_text.py", ] # Files pulled from the package directory rather than sagemaker_scripts/. SHARED_FILES = ["task_types.py"] -# S3 key for the packaged scripts -SCRIPTS_S3_KEY = "scripts/sourcedir.tar.gz" +# The dependency file each DLC family gets, packaged into the archive AS +# ``requirements.txt`` — which is the only name the DLC installs from. +# +# One shared file cannot serve all three. The VLM family needs peft and +# bitsandbytes, and bitsandbytes requires torch>=2.4 while the text container +# is torch 2.1: adding them to the shared file would break dependency +# installation for every existing text job. Selecting the file per family is +# the same reasoning that already keys the DLC image by family. +REQUIREMENTS_BY_FAMILY = { + task_types.DLC_FAMILY_TEXT: "requirements.txt", + task_types.DLC_FAMILY_VISION: "requirements.txt", + task_types.DLC_FAMILY_VLM: "requirements-vlm.txt", +} + +#: Name the requirements file must have inside the archive. +PACKAGED_REQUIREMENTS_NAME = "requirements.txt" + + +def scripts_s3_key(dlc_family: str) -> str: + """S3 key for one family's source directory. + + Keyed by family because the archives differ only in their requirements + file; a single key would make the last job to run overwrite the + dependency set of the other families. + """ + return f"scripts/sourcedir-{dlc_family}.tar.gz" class ScriptPackagingService: @@ -54,10 +80,10 @@ def __init__(self, s3_client=None, bucket_name: Optional[str] = None): self._bucket = bucket_name or os.environ.get( "S3_FINE_TUNING_BUCKET_NAME", "fine-tuning-data" ) - self._cached_s3_uri: Optional[str] = None + self._cached_s3_uris: dict = {} @staticmethod - def _source_paths() -> list: + def _source_paths(dlc_family: str) -> list: """Return (archive name, source path) for every packaged file. Ordered deterministically so the content hash is stable across calls — @@ -65,19 +91,26 @@ def _source_paths() -> list: """ paths = [(name, os.path.join(SCRIPTS_DIR, name)) for name in SCRIPT_FILES] paths += [(name, os.path.join(PACKAGE_DIR, name)) for name in SHARED_FILES] + + requirements = REQUIREMENTS_BY_FAMILY.get( + dlc_family, REQUIREMENTS_BY_FAMILY[task_types.DLC_FAMILY_TEXT] + ) + paths.append( + (PACKAGED_REQUIREMENTS_NAME, os.path.join(SCRIPTS_DIR, requirements)) + ) return sorted(paths, key=lambda pair: pair[0]) - def _compute_content_hash(self) -> str: + def _compute_content_hash(self, dlc_family: str) -> str: """Compute SHA256 hash of all script file contents.""" hasher = hashlib.sha256() - for name, filepath in self._source_paths(): + for name, filepath in self._source_paths(dlc_family): if os.path.exists(filepath): hasher.update(name.encode("utf-8")) with open(filepath, "rb") as f: hasher.update(f.read()) return hasher.hexdigest() - def _create_tar_gz(self) -> bytes: + def _create_tar_gz(self, dlc_family: str) -> bytes: """Create an in-memory tar.gz archive of the scripts. Files are added at the root level of the archive (no subdirectory), @@ -85,7 +118,7 @@ def _create_tar_gz(self) -> bytes: """ buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: - for name, filepath in self._source_paths(): + for name, filepath in self._source_paths(dlc_family): if os.path.exists(filepath): tar.add(filepath, arcname=name) else: @@ -93,12 +126,12 @@ def _create_tar_gz(self) -> bytes: buf.seek(0) return buf.read() - def _check_s3_hash(self, content_hash: str) -> bool: + def _check_s3_hash(self, content_hash: str, key: str) -> bool: """Check if the S3 object exists and has a matching content hash.""" try: response = self._s3.head_object( Bucket=self._bucket, - Key=SCRIPTS_S3_KEY, + Key=key, ) s3_hash = response.get("Metadata", {}).get("content-hash", "") return s3_hash == content_hash @@ -107,33 +140,36 @@ def _check_s3_hash(self, content_hash: str) -> bool: return False raise - def ensure_scripts_uploaded(self) -> str: - """Ensure scripts tar.gz is uploaded to S3. Returns the S3 URI. + def ensure_scripts_uploaded(self, task_type: Optional[str] = None) -> str: + """Ensure the source dir for ``task_type``'s family is in S3. Uses content-hash caching to skip re-upload if scripts haven't changed. - Caches the URI in memory after the first successful call. + Caches the URI per family in memory after the first successful call. """ - if self._cached_s3_uri: - return self._cached_s3_uri + dlc_family = task_types.get_task_spec(task_type).dlc_family + + cached = self._cached_s3_uris.get(dlc_family) + if cached: + return cached - content_hash = self._compute_content_hash() + key = scripts_s3_key(dlc_family) + content_hash = self._compute_content_hash(dlc_family) - if not self._check_s3_hash(content_hash): - tar_bytes = self._create_tar_gz() + if not self._check_s3_hash(content_hash, key): + tar_bytes = self._create_tar_gz(dlc_family) self._s3.put_object( Bucket=self._bucket, - Key=SCRIPTS_S3_KEY, + Key=key, Body=tar_bytes, Metadata={"content-hash": content_hash}, ) - logger.info( - f"Uploaded scripts tar.gz to s3://{self._bucket}/{SCRIPTS_S3_KEY}" - ) + logger.info(f"Uploaded scripts tar.gz to s3://{self._bucket}/{key}") else: - logger.debug("Scripts tar.gz already up-to-date in S3") + logger.debug(f"Scripts tar.gz already up-to-date in S3 for {dlc_family}") - self._cached_s3_uri = f"s3://{self._bucket}/{SCRIPTS_S3_KEY}" - return self._cached_s3_uri + uri = f"s3://{self._bucket}/{key}" + self._cached_s3_uris[dlc_family] = uri + return uri # Singleton access diff --git a/backend/src/apis/app_api/fine_tuning/task_types.py b/backend/src/apis/app_api/fine_tuning/task_types.py index 5526b02be..bb001687d 100644 --- a/backend/src/apis/app_api/fine_tuning/task_types.py +++ b/backend/src/apis/app_api/fine_tuning/task_types.py @@ -34,6 +34,7 @@ TEXT_CLASSIFICATION = "text-classification" IMAGE_CLASSIFICATION = "image-classification" IMAGE_TEXT_CLASSIFICATION = "image-text-classification" +IMAGE_TEXT_TO_TEXT = "image-text-to-text" #: Task assumed for a job record written before task types existed, and for a #: request that omits the field. Must stay ``TEXT_CLASSIFICATION`` — every @@ -53,6 +54,12 @@ # exact container they were validated on. DLC_FAMILY_TEXT = "text" DLC_FAMILY_VISION = "vision" +# Generative VLMs need PEFT and bitsandbytes on top of the vision stack. +# bitsandbytes requires torch>=2.4, which the text container (torch 2.1) +# cannot satisfy, so the dependency set cannot simply be added to the shared +# requirements file — the family is what selects the right one at packaging +# time. See ``script_packaging_service.REQUIREMENTS_BY_FAMILY``. +DLC_FAMILY_VLM = "vlm" # ========================================================================= @@ -70,8 +77,9 @@ class TaskSpec: # --- Training record contract ------------------------------------- #: Columns every training record must carry. required_columns: Tuple[str, ...] - #: Column holding the target class. - label_column: str + #: Column holding the target class, or None for a generative task, which + #: has no fixed label set. + label_column: Optional[str] #: Column holding an image path relative to the archive root, or None for #: text-only tasks. image_column: Optional[str] @@ -104,6 +112,15 @@ class TaskSpec: default_instance_type: str default_hyperparameters: Mapping[str, str] + # --- Generative tasks ---------------------------------------------- + #: Column holding the target text a generative task learns to produce. + #: None for the classification tasks. Defaulted so the three existing + #: specs are untouched. + response_column: Optional[str] = None + #: True when the model emits free text rather than a class distribution. + #: Drives the output contract: probability columns vs a text column. + is_generative: bool = False + def supports_extension(self, filename: str) -> bool: """True when ``filename`` is an acceptable training upload.""" return filename.lower().endswith(self.upload_extensions) @@ -227,9 +244,10 @@ def supports_inference_extension(self, filename: str) -> bool: # # "image-text-to-text" (LLaVA, Qwen-VL) and "visual-question-answering" # (ViLT, BLIP) are generative or fusion models with no text tower to - # pool, and are excluded on purpose: allowing them would let the - # pre-flight pass a model that only fails later, on a billed GPU, - # inside the trainer's dual-encoder check. + # pool, and stay excluded here: allowing them would let the pre-flight + # pass a model that only fails later, on a billed GPU, inside the + # trainer's dual-encoder check. Generative VLMs have their own task — + # see IMAGE_TEXT_TO_TEXT below. hf_pipeline_tags=("zero-shot-image-classification",), default_instance_type="ml.g6.xlarge", default_hyperparameters={ @@ -239,6 +257,55 @@ def supports_inference_extension(self, filename: str) -> bool: "context_length": "77", }, ), + IMAGE_TEXT_TO_TEXT: TaskSpec( + task_type=IMAGE_TEXT_TO_TEXT, + display_name="Image + text to text", + description=( + "Teach a vision-language model to answer about an image in your " + "own style or vocabulary. Upload a .zip containing a manifest " + '(CSV/JSONL/JSON) with "image", "prompt" and "response" fields, ' + "plus the image files the manifest points at." + ), + required_columns=("image", "prompt", "response"), + # Generative: there is no class list, so no label column and no + # softmax. ``response`` is the target text, not a category. + label_column=None, + image_column="image", + text_column="prompt", + response_column="response", + is_generative=True, + upload_extensions=(".zip",), + manifest_extensions=_MANIFEST_EXTENSIONS, + requires_archive=True, + inference_upload_extensions=(".zip",), + inference_content_type="application/zip", + inference_max_payload_mb=100, + dlc_family=DLC_FAMILY_VLM, + hf_pipeline_tags=("image-text-to-text",), + # 48GB (L40S). A 7B VLM in 4-bit needs ~5GB of weights but the + # activations for a high-resolution image are what actually size the + # card: LLaVA-1.6's AnyRes tiling emits up to 2880 image tokens per + # image. The 24GB g6/g5 instances OOM on the larger checkpoints, so + # the default starts where the whole catalog fits. + default_instance_type="ml.g6e.xlarge", + default_hyperparameters={ + **_COMMON_HYPERPARAMETERS, + # LoRA wants a markedly higher LR than full fine-tuning: only the + # adapter matrices move, and they start at zero. + "learning_rate": "1e-4", + # One sequence per step, recovered to an effective batch of 8 by + # accumulation. A VLM sequence is thousands of tokens, so a + # literal batch of 16 OOMs on any instance we offer. + "per_device_train_batch_size": "1", + "gradient_accumulation_steps": "8", + "context_length": "1024", + "load_in_4bit": "true", + "lora_r": "16", + "lora_alpha": "32", + "lora_dropout": "0.05", + "max_new_tokens": "256", + }, + ), } #: Stable, deterministic ordering for anything user-facing. @@ -246,6 +313,7 @@ def supports_inference_extension(self, filename: str) -> bool: TEXT_CLASSIFICATION, IMAGE_CLASSIFICATION, IMAGE_TEXT_CLASSIFICATION, + IMAGE_TEXT_TO_TEXT, ) #: Task types whose upload is an archive of a manifest plus image files. @@ -253,6 +321,13 @@ def supports_inference_extension(self, filename: str) -> bool: t for t in TASK_TYPES if TASK_SPECS[t].requires_archive ) +#: Task types that emit free text instead of a class distribution. These are +#: the ones whose result file carries an output column rather than one +#: probability column per class. +GENERATIVE_TASK_TYPES: Tuple[str, ...] = tuple( + t for t in TASK_TYPES if TASK_SPECS[t].is_generative +) + def get_task_spec(task_type: Optional[str]) -> TaskSpec: """Return the spec for ``task_type``. @@ -275,3 +350,8 @@ def get_task_spec(task_type: Optional[str]) -> TaskSpec: def requires_images(task_type: Optional[str]) -> bool: """True when the task's training records reference image files.""" return get_task_spec(task_type).image_column is not None + + +def is_generative(task_type: Optional[str]) -> bool: + """True when the task produces free text rather than class probabilities.""" + return get_task_spec(task_type).is_generative diff --git a/backend/src/apis/app_api/kb_migration/document_reconciler.py b/backend/src/apis/app_api/kb_migration/document_reconciler.py new file mode 100644 index 000000000..ec7f5c745 --- /dev/null +++ b/backend/src/apis/app_api/kb_migration/document_reconciler.py @@ -0,0 +1,755 @@ +"""Dead-letter reconciler for stranded managed-KB documents. + +Task 16.5 (HANDOFF §5.37). The companion to :mod:`reconciler`, which reconciles +whole knowledge bases against ``ListKnowledgeBases``; this one reconciles +individual ``DOC#`` rows against Bedrock's *document* view. + +The gap it closes +----------------- +On the managed path a document's ``DOC#`` status is written by exactly one writer: +the ingestion consumer (``kb_migration/ingestion_consumer.py``). That consumer +polls Bedrock until the document is genuinely retrievable and only then writes +``complete``. It is the right design — but it is the *only* writer, and it runs +inside a Lambda whose asynchronous retry is capped at **2** attempts (a hard +service limit). When an event exhausts those retries and dead-letters, the row is +left in a non-terminal state (``uploading`` / ``chunking`` / ``embedding``) with +**nothing left to revisit it** — even though Bedrock frequently finished indexing +the document seconds after the final attempt was dead-lettered, so the content is +sitting in the knowledge base fully retrievable. + +That combination is invisible and permanent, because the retrieval status filter +(``rag_service._filter_vectors_by_document_status``) serves **only** ``complete`` +documents. A stranded row's chunks are dropped from every query: the user was told +their upload worked, the content really is in the knowledge base, and the +assistant will never cite it. Two such documents occurred in dev and both needed a +manual DynamoDB edit. + +This module is the missing second writer. Once a day it finds ``DOC#`` rows stuck +non-terminal past a grace period, asks Bedrock the ground truth for each, and — the +§5.37 case — drives a stranded-but-retrievable document to ``complete``. It also +handles the two neighbouring outcomes the same probe reveals: a document Bedrock +reports ``FAILED`` is driven to ``failed`` (it was never going to recover), and a +document Bedrock has never heard of (``NOT_FOUND`` — dead-lettered *before* the +ingest was ever accepted) is **re-ingested** from the bytes still in S3. That +re-ingest is the one-click retry of task 14.4 arriving on a schedule instead of a +button. + +Ground truth comes from the consumer, not a second copy +------------------------------------------------------- +Every decision here reuses the consumer's own probes — :func:`document_status` +(``GetKnowledgeBaseDocuments``), the ``equals``-on-``document_id`` retrievability +search, its status-set constants, and its terminal-write function. That reuse is +deliberate: those functions carry three hard-won lessons (§5.37 the poll budget, +§5.38 that an unfiltered retrievability search finds the wrong document, §5.39 +that the live service returns statuses the SDK enum omits). A reconciler that +re-derived any of them would be a fourth place for the same bug to live. The one +thing this module does *not* reuse is the consumer's *polling* — it takes a single +retrievability reading per document rather than waiting, because it is sweeping a +fleet, not shepherding one upload. + +Report-only, and armed separately +---------------------------------- +Modelled on :mod:`reconciler`. It ships **disarmed**: it logs exactly what it +would have done and writes nothing, so its judgement can be checked against real +data before it is trusted to correct records. Arming is one flag, +:data:`FLAG_DOC_RECONCILER_ARMED`, and an **empty string reads as off** — an unset +GitHub Actions variable expands to ``""``. The per-run action limit +(:func:`max_actions_per_run`) applies in **both** modes, so a report never claims +more corrections than an armed run would actually make. + +The grace gate is a pure function of the row's own ``updatedAt``, never of +discovery time — the same discipline as the KB reconciler's ``createdAt`` gate. A +row whose ``updatedAt`` cannot be read is left alone: without proof that a document +has been stuck *longer than a legitimate in-flight ingestion could take*, touching +it risks racing an upload that is still, correctly, being worked on. + +Import boundary +--------------- +Module-level imports are stdlib plus the stdlib-only ``ingestion_consumer`` / +``reconciler`` / ``kb_backend.records`` siblings; ``boto3`` and the heavy +``ManagedKbBackend`` are function-local. DynamoDB is reached through the raw table +resource, matching every other module in this package. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, Iterator, List, Optional + +from apis.app_api.kb_migration import ingestion_consumer as ic +from apis.shared.kb_backend.metrics import emit_count + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# ── Flags ──────────────────────────────────────────────────────────────────── +# +# The arming flag. Absent, empty, or anything not in the truthy set means the +# reconciler reports and corrects nothing. Same allow-list as the KB reconciler +# and the dispatcher: the failure being designed around is a value that is present +# but empty (``bool("")`` is off by luck, ``bool("false")`` is not). +FLAG_DOC_RECONCILER_ARMED = "MANAGED_KB_DOC_RECONCILER_ARMED" + +_TRUTHY = frozenset({"1", "true", "yes", "on", "enabled"}) + +# ── Document statuses ───────────────────────────────────────────────────────── +# +# The non-terminal ``DOC#`` states a stranded document can be parked in. Taken to +# match ``apis/app_api/documents/ingestion/status.py``'s ``DocumentStatus`` literal +# minus its terminal members. ``deleting`` is deliberately NOT here: a +# soft-deleted document is being removed on purpose and must never be resurrected +# to ``complete``. +NON_TERMINAL_STATUSES = frozenset({"uploading", "chunking", "embedding"}) + +# ── Tunables, resolved at call time ────────────────────────────────────────── +# +# Read inside the functions that use them rather than bound as default arguments: +# a default argument is evaluated once at import, so a test overriding it silently +# gets the production value instead. Same reason the KB reconciler does this. + +#: A document younger than this is not yet evidence of a dead-letter — it may still +#: be legitimately in flight. The ingestion consumer waits up to +#: ``INDEXED_POLL_TIMEOUT_SECONDS`` (600 s) inside one invocation, and an event +#: gets 1 + 2 deliveries spread over a few minutes before it dead-letters, so a +#: genuinely-working document can still be non-terminal for ~15 minutes. 60 minutes +#: is comfortably past that, which is the correct direction: the cost of waiting one +#: more daily pass is nil, and the cost of racing an in-flight upload is marking it +#: from under the consumer. +STUCK_MIN_AGE_MINUTES = 60.0 + +#: Bounds the corrective work of a single run — mark-completes, re-ingests and +#: fails together — so a bug in the join, or a sudden flood of stranded rows, +#: costs at most this many actions before someone reads the report. Applied in +#: report-only mode too, so the report is trustworthy. +MAX_ACTIONS_PER_RUN = 25 + +#: Hard ceiling above which the env override is ignored. A larger sweep should +#: require repeated observed runs, not a variable edit. +MAX_ACTIONS_CEILING = 100 + +#: Bounds the join itself. A reconciler that walked an unbounded table would time +#: out mid-pass and produce a partial report indistinguishable from a complete one. +MAX_RECORDS_PER_RUN = 5000 + +# ── Action kinds ────────────────────────────────────────────────────────────── +ACTION_MARK_COMPLETE = "mark_complete" +ACTION_RE_INGEST = "re_ingest" +ACTION_MARK_FAILED = "mark_failed" + +# ── Metrics ────────────────────────────────────────────────────────────────── +METRIC_STRANDED_FOUND = "KbStrandedDocumentsFound" +METRIC_MARKED_COMPLETE = "KbStrandedDocumentsCompleted" +METRIC_RE_INGESTED = "KbStrandedDocumentsReingested" +METRIC_MARKED_FAILED = "KbStrandedDocumentsFailed" +METRIC_LIMIT_REACHED = "KbDocumentReconcilerLimitReached" + + +@dataclass +class PlannedAction: + """One correction the reconciler intends, and the evidence for it.""" + + assistant_id: str + document_id: str + kind: str + current_status: str + bedrock_status: str + performed: bool = False + error: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "assistantId": self.assistant_id, + "documentId": self.document_id, + "kind": self.kind, + "currentStatus": self.current_status, + "bedrockStatus": self.bedrock_status, + "performed": self.performed, + "error": self.error, + } + + +@dataclass +class DocumentReconcileReport: + """What one run found and what it did (or, disarmed, would have done). + + ``armed`` lives on the report, not only in the logs, so a stored artifact is + self-describing: an operator reading last night's output should not have to go + and check what the flag was set to at the time. + """ + + armed: bool = False + managed_records: int = 0 + documents_scanned: int = 0 + stranded: int = 0 + planned_actions: List[PlannedAction] = field(default_factory=list) + skipped_too_young: List[str] = field(default_factory=list) + skipped_in_flight: List[str] = field(default_factory=list) + skipped_not_retrievable: List[str] = field(default_factory=list) + limit_reached: bool = False + + @property + def actions_performed(self) -> int: + return sum(1 for action in self.planned_actions if action.performed) + + def _count(self, kind: str) -> int: + return sum(1 for action in self.planned_actions if action.kind == kind) + + def to_dict(self) -> Dict[str, Any]: + return { + "armed": self.armed, + "mode": "armed" if self.armed else "report-only", + "managedRecords": self.managed_records, + "documentsScanned": self.documents_scanned, + "stranded": self.stranded, + "plannedActions": [action.to_dict() for action in self.planned_actions], + "plannedByKind": { + ACTION_MARK_COMPLETE: self._count(ACTION_MARK_COMPLETE), + ACTION_RE_INGEST: self._count(ACTION_RE_INGEST), + ACTION_MARK_FAILED: self._count(ACTION_MARK_FAILED), + }, + "actionsPerformed": self.actions_performed, + "skippedTooYoung": self.skipped_too_young, + "skippedInFlight": self.skipped_in_flight, + "skippedNotRetrievable": self.skipped_not_retrievable, + "limitReached": self.limit_reached, + } + + +# ── Flag and tunable readers ───────────────────────────────────────────────── +def doc_reconciler_armed() -> bool: + """Whether the reconciler may write. Defaults to **off**. + + An empty string is off: an unset repository or environment variable expands to + ``""`` in GitHub Actions, and stamping a truthiness test on the raw value is + the exact bug the KB reconciler was bitten by. + """ + raw = os.environ.get(FLAG_DOC_RECONCILER_ARMED) + if not raw: + return False + return raw.strip().lower() in _TRUTHY + + +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if not raw: + return default + try: + return float(raw) + except ValueError: + logger.warning(f"{name}={raw!r} is not a number; falling back to {default}") + return default + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if not raw: + return default + try: + return int(raw) + except ValueError: + logger.warning(f"{name}={raw!r} is not an integer; falling back to {default}") + return default + + +def stuck_min_age_minutes() -> float: + return _env_float("MANAGED_KB_DOC_STUCK_MIN_AGE_MINUTES", STUCK_MIN_AGE_MINUTES) + + +def max_actions_per_run() -> int: + """The per-run action bound, clamped so the environment cannot lift it. + + The env var may lower the limit but not raise it past + :data:`MAX_ACTIONS_CEILING`. A bound any variable can set to a million is not a + bound; this one caps how much a single bad run can churn before its report is + read. + """ + requested = _env_int("MANAGED_KB_DOC_RECONCILER_MAX_ACTIONS", MAX_ACTIONS_PER_RUN) + if requested > MAX_ACTIONS_CEILING: + logger.warning( + f"MANAGED_KB_DOC_RECONCILER_MAX_ACTIONS={requested} exceeds the ceiling " + f"of {MAX_ACTIONS_CEILING}; clamping. Run the reconciler repeatedly " + f"rather than raising this." + ) + return MAX_ACTIONS_CEILING + return max(requested, 0) + + +def max_records_per_run() -> int: + return _env_int("MANAGED_KB_DOC_RECONCILER_MAX_RECORDS", MAX_RECORDS_PER_RUN) + + +# ── DynamoDB plumbing ──────────────────────────────────────────────────────── +def _table(): + import boto3 + + return boto3.resource("dynamodb").Table(os.environ["DYNAMODB_ASSISTANTS_TABLE_NAME"]) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def iter_document_records(assistant_id: str) -> Iterator[Dict[str, Any]]: + """Every ``DOC#`` row for one assistant, paging the query to exhaustion. + + Paged for the same reason the reconciler pages its scans: a truncated read + would make a stranded document on a later page look absent, and the run would + silently skip it. + """ + from boto3.dynamodb.conditions import Key + + table = _table() + kwargs: Dict[str, Any] = { + "KeyConditionExpression": Key("PK").eq(f"AST#{assistant_id}") + & Key("SK").begins_with("DOC#"), + } + while True: + response = table.query(**kwargs) + for item in response.get("Items") or []: + yield item + start = response.get("LastEvaluatedKey") + if not start: + return + kwargs["ExclusiveStartKey"] = start + + +# ── Age gate (pure function of the row's own updatedAt) ────────────────────── +def document_age_minutes(updated_at: Any, now: Optional[datetime] = None) -> Optional[float]: + """Minutes since the row's ``updatedAt``, or ``None`` if it cannot be read. + + Reuses the KB reconciler's timestamp parser so the accepted shapes (aware + datetime, ISO string, epoch number) are identical across the feature. + """ + from apis.app_api.kb_migration.reconciler import parse_aws_timestamp + + stamped = parse_aws_timestamp(updated_at) + if stamped is None: + return None + return ((now or _now()) - stamped).total_seconds() / 60.0 + + +def document_is_stuck_long_enough( + updated_at: Any, + now: Optional[datetime] = None, + min_age_minutes: Optional[float] = None, +) -> bool: + """Whether a non-terminal row has been stuck long enough to reconcile. + + A missing or unparseable ``updatedAt`` returns ``False`` — fail-safe. Without + proof the row has been stuck longer than a legitimate ingestion can take, + correcting it risks racing the consumer that is still, correctly, working it. + The input is the row's own ``updatedAt``, never discovery time: the answer must + not depend on when this process happened to look. + """ + if min_age_minutes is None: + min_age_minutes = stuck_min_age_minutes() + from apis.app_api.kb_migration.reconciler import parse_aws_timestamp + + stamped = parse_aws_timestamp(updated_at) + if stamped is None: + return False + return (now or _now()) - stamped > timedelta(minutes=min_age_minutes) + + +# ── Retrievability: a single reading, not a poll ───────────────────────────── +def is_retrievable(backend: Any, kb_ref: str, document_id: str) -> bool: + """Whether a retrieval really returns ``document_id`` right now. + + ONE reading, not the consumer's poll: this sweeps a fleet, so waiting per + document would blow the run's time budget. The ``equals`` filter on + ``document_id`` is the load-bearing part and is the whole reason §5.38 exists + — an *unfiltered* search for a document id returns whatever the reranker + prefers (a document id is meaningless to an embedding model), so it confirms + the wrong document as retrievable and gets worse as a knowledge base grows. + Filtered, a non-empty result *is* proof and an empty one is a true negative. + + A probe that itself errors is treated as "not retrievable" for this pass — the + reconciler simply leaves the row for the next run rather than acting on a + failed reading. + """ + import asyncio + + document_filter = {"equals": {"key": "document_id", "value": document_id}} + try: + chunks = asyncio.run( + backend.search(kb_ref, document_id, 5, retrieval_filter=document_filter) + ) + except Exception as exc: # noqa: BLE001 - a probe failure is not a verdict + logger.warning(f"retrievability probe for {document_id} failed: {exc}") + return False + + # The filter already restricts the result set to this document; the per-chunk + # check is a belt-and-braces guard against a filter a future API change ignores. + for chunk in chunks or []: + metadata = getattr(chunk, "metadata", None) or {} + if metadata.get("document_id") == document_id: + return True + return False + + +# ── The run ────────────────────────────────────────────────────────────────── +def reconcile_documents( + client=None, + backend_factory: Optional[Callable[[str], Any]] = None, + armed: Optional[bool] = None, + now: Optional[datetime] = None, +) -> DocumentReconcileReport: + """One document-reconciliation pass. + + ``armed`` defaults to :func:`doc_reconciler_armed`, i.e. to the flag, i.e. to + off. It is an argument only so a test can exercise the armed path without + mutating process environment — never so a caller can conveniently turn writing + on. + + ``backend_factory`` builds a :class:`ManagedKbBackend` for an assistant; it is + injectable so tests can supply a stub that models Bedrock's document view. The + default constructs a real backend keyed on the assistant id (``App_KB_Id`` == + ``assistant_id`` in this phase) with the injected control-plane ``client``. + """ + from apis.shared.kb_backend.records import ENGINE_MANAGED, resolve_engine + + if armed is None: + armed = doc_reconciler_armed() + if now is None: + now = _now() + if backend_factory is None: + backend_factory = _default_backend_factory(client) + + report = DocumentReconcileReport(armed=armed) + record_limit = max_records_per_run() + action_limit = max_actions_per_run() + min_age = stuck_min_age_minutes() + + for record in _iter_managed_records(record_limit, report): + if resolve_engine(record) != ENGINE_MANAGED: + continue + if not record.get("awsKbId"): + # Managed but not provisioned yet: there is no knowledge base to probe, + # so a non-terminal document here is waiting on provisioning, not + # dead-lettered. + continue + + assistant_id = _assistant_id_of(record) + if not assistant_id: + continue + report.managed_records += 1 + backend = None # built lazily, only if this assistant has a stranded row + + for document in iter_document_records(assistant_id): + report.documents_scanned += 1 + status = str(document.get("status") or "") + if status not in NON_TERMINAL_STATUSES: + continue + + document_id = str(document.get("documentId") or "") + if not document_id: + logger.warning( + f"skipping malformed DOC# row {document.get('PK')}/" + f"{document.get('SK')}: no documentId" + ) + continue + + if not document_is_stuck_long_enough( + document.get("updatedAt"), now=now, min_age_minutes=min_age + ): + report.skipped_too_young.append(document_id) + continue + + report.stranded += 1 + + if backend is None: + backend = backend_factory(assistant_id) + + if report.limit_reached or len(report.planned_actions) >= action_limit: + report.limit_reached = True + emit_count(METRIC_LIMIT_REACHED) + logger.warning( + f"per-run action limit of {action_limit} reached; {document_id} " + f"and any further stranded documents are left for the next run" + ) + break + + _reconcile_one( + assistant_id, document, document_id, status, backend, armed, report + ) + + if report.limit_reached: + break + + if report.stranded: + emit_count(METRIC_STRANDED_FOUND, value=report.stranded) + + logger.info( + f"document reconcile complete: mode={'armed' if armed else 'report-only'} " + f"managedRecords={report.managed_records} " + f"documentsScanned={report.documents_scanned} stranded={report.stranded} " + f"planned={len(report.planned_actions)} performed={report.actions_performed} " + f"tooYoung={len(report.skipped_too_young)} " + f"inFlight={len(report.skipped_in_flight)} " + f"notRetrievable={len(report.skipped_not_retrievable)} " + f"limitReached={report.limit_reached}" + ) + return report + + +def _iter_managed_records( + record_limit: int, report: DocumentReconcileReport +) -> Iterator[Dict[str, Any]]: + """KB_Records, bounded, so a huge table cannot make the pass run forever. + + Reuses the KB reconciler's ``iter_kb_records`` (the ``SK begins_with KB#`` + scan) rather than a second implementation, so tombstones and key prefixes are + handled identically. + """ + from apis.app_api.kb_migration.reconciler import iter_kb_records + + seen = 0 + for record in iter_kb_records(): + if seen >= record_limit: + report.limit_reached = True + logger.warning( + f"stopping the KB_Record walk at {record_limit}; this run is partial" + ) + return + seen += 1 + yield record + + +def _reconcile_one( + assistant_id: str, + document: Dict[str, Any], + document_id: str, + status: str, + backend: Any, + armed: bool, + report: DocumentReconcileReport, +) -> None: + """Classify one stranded document against Bedrock and act (or report). + + The classification mirrors the ingestion consumer's own handling exactly, + because it uses the consumer's status sets — the point of a reconciler is to + reach the terminal state the dead-lettered invocation would have reached, not + to invent a new policy. + """ + bedrock_status, bedrock_updated_at = ic.document_status(backend, assistant_id, document_id) + + # Still indexing: not stranded, just slow. Leave it — a later pass (or a + # redelivery that beat the DLQ) will finish it. The grace gate makes this rare. + if bedrock_status in ic.DOC_STATUSES_IN_FLIGHT: + report.skipped_in_flight.append(document_id) + logger.info( + f"document {document_id} is {bedrock_status} in the knowledge base; " + f"still indexing, leaving it" + ) + return + + # Terminal failure on Bedrock's side: retrying cannot help, so drive the row to + # the terminal state the consumer would have written. + if bedrock_status in ic.DOC_STATUSES_FAILED: + _plan( + report, assistant_id, document_id, ACTION_MARK_FAILED, status, bedrock_status, + armed, + lambda: ic.set_document_terminal( + assistant_id, document_id, ic.STATUS_FAILED, + error=f"the knowledge base reports this document as {bedrock_status}", + ), + METRIC_MARKED_FAILED, + f"[report-only] WOULD mark {document_id} failed ({bedrock_status})", + ) + return + + # Indexed (fully or partially): if it is genuinely retrievable, this is the + # §5.37 case — a stranded-but-servable document — and it is driven to complete. + if bedrock_status in (ic.DOC_STATUS_INDEXED, *ic.DOC_STATUSES_PARTIAL): + if not is_retrievable(backend, assistant_id, document_id): + # Indexed but not yet queryable, or the probe failed. Do NOT claim + # complete: that is exactly the "upload worked but the assistant cannot + # see it" report the consumer exists to prevent. Leave it for next run. + report.skipped_not_retrievable.append(document_id) + logger.info( + f"document {document_id} is {bedrock_status} but not retrievable " + f"this pass; leaving it short of complete" + ) + return + indexed_at = bedrock_updated_at or ic._now_iso() + _plan( + report, assistant_id, document_id, ACTION_MARK_COMPLETE, status, bedrock_status, + armed, + lambda: ic.set_document_terminal( + assistant_id, document_id, ic.STATUS_COMPLETE, + indexed_at=indexed_at, retrievable_at=ic._now_iso(), + ), + METRIC_MARKED_COMPLETE, + f"[report-only] WOULD mark {document_id} complete ({bedrock_status}, " + f"confirmed retrievable)", + ) + return + + # NOT_FOUND: Bedrock never received this document. The event dead-lettered + # before the ingest was accepted, so the bytes in S3 were never submitted. + # Re-ingest them — the scheduled form of task 14.4's one-click retry. + if bedrock_status == ic.DOC_STATUS_NOT_FOUND: + source = _document_source(document, document_id) + if source is None: + logger.warning( + f"document {document_id} is NOT_FOUND but its row lacks an s3Key; " + f"cannot re-ingest, leaving it" + ) + report.skipped_not_retrievable.append(document_id) + return + _plan( + report, assistant_id, document_id, ACTION_RE_INGEST, status, bedrock_status, + armed, + lambda: _reingest(backend, assistant_id, source), + METRIC_RE_INGESTED, + f"[report-only] WOULD re-ingest {document_id} (NOT_FOUND in the " + f"knowledge base; bytes still in S3)", + ) + return + + # Any other value: the live service returns statuses the SDK enum omits + # (§5.39). Treat unknown as "still working" and leave it, logging the value so + # it can be classified rather than silently mishandled. + report.skipped_in_flight.append(document_id) + logger.warning( + f"document {document_id} reported unrecognised Bedrock status " + f"{bedrock_status!r}; treating it as in-flight and leaving it" + ) + + +def _plan( + report: DocumentReconcileReport, + assistant_id: str, + document_id: str, + kind: str, + current_status: str, + bedrock_status: str, + armed: bool, + perform: Callable[[], None], + metric: str, + report_only_message: str, +) -> None: + """Record a planned action and, if armed, perform it. + + The action is appended to the report whether or not it runs, so the report-only + artifact describes exactly what an armed run would do. When armed, a failure is + captured on the action rather than raised — one bad document must not end the + sweep — matching the KB reconciler's per-orphan error handling. + """ + action = PlannedAction( + assistant_id=assistant_id, + document_id=document_id, + kind=kind, + current_status=current_status, + bedrock_status=bedrock_status, + ) + report.planned_actions.append(action) + + if not armed: + logger.warning(f"{report_only_message}. Set {FLAG_DOC_RECONCILER_ARMED} to arm.") + return + + try: + perform() + action.performed = True + emit_count(metric) + logger.info(f"{kind} performed for document {document_id}") + except Exception as exc: # noqa: BLE001 - one bad document must not end the run + action.error = str(exc) + logger.error(f"{kind} failed for document {document_id}: {exc}", exc_info=True) + + +def _reingest(backend: Any, assistant_id: str, source: Any) -> None: + import asyncio + + asyncio.run(backend.ingest(assistant_id, source)) + + +def _document_source(document: Dict[str, Any], document_id: str) -> Optional[Any]: + """Reconstruct a ``DocumentSource`` for re-ingest from the ``DOC#`` row. + + The row carries ``s3Key`` and ``filename`` (from the ``Document`` model's + aliases), which is everything the managed backend needs to re-submit bytes + already in S3. Returns ``None`` when ``s3Key`` is absent — an old row that + predates the attribute cannot be re-ingested from here and is left for a + re-upload. + """ + from apis.shared.kb_backend.protocol import DocumentSource + + s3_key = document.get("s3Key") + if not s3_key: + return None + filename = document.get("filename") or "" + return DocumentSource(document_id=document_id, filename=filename, s3_key=str(s3_key)) + + +def _default_backend_factory(client) -> Callable[[str], Any]: + """Build a real :class:`ManagedKbBackend` per assistant, sharing one client. + + Function-local import: ``ManagedKbBackend`` pulls in the retrieval stack and + must not be a module-level import in this size-constrained Lambda. + """ + def factory(_assistant_id: str) -> Any: + from apis.shared.kb_backend.managed_backend import ManagedKbBackend + + return ManagedKbBackend(agent_client=client) + + return factory + + +def _assistant_id_of(record: Dict[str, Any]) -> str: + pk = str(record.get("PK") or "") + return pk[len("AST#") :] if pk.startswith("AST#") else "" + + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """Scheduled entry point. Returns the report so it lands in the invocation log. + + The invocation event is deliberately **not** consulted for arming, for the same + reason as the KB reconciler: an event payload is the one input an operator does + not review, so honouring an ``armed`` field in it would let any principal + holding ``lambda:InvokeFunction`` correct records while every reviewable setting + still said report-only. If the event disagrees with the flag, the flag wins and + the disagreement is logged. + """ + requested = (event or {}).get("armed") + if requested is not None: + logger.warning( + f"ignoring armed={requested!r} from the invocation event: arming is " + f"controlled only by {FLAG_DOC_RECONCILER_ARMED}" + ) + report = reconcile_documents() + return {"statusCode": 200, "report": report.to_dict()} + + +__all__ = [ + "ACTION_MARK_COMPLETE", + "ACTION_MARK_FAILED", + "ACTION_RE_INGEST", + "FLAG_DOC_RECONCILER_ARMED", + "MAX_ACTIONS_CEILING", + "MAX_ACTIONS_PER_RUN", + "MAX_RECORDS_PER_RUN", + "METRIC_LIMIT_REACHED", + "METRIC_MARKED_COMPLETE", + "METRIC_MARKED_FAILED", + "METRIC_RE_INGESTED", + "METRIC_STRANDED_FOUND", + "NON_TERMINAL_STATUSES", + "STUCK_MIN_AGE_MINUTES", + "DocumentReconcileReport", + "PlannedAction", + "doc_reconciler_armed", + "document_age_minutes", + "document_is_stuck_long_enough", + "is_retrievable", + "iter_document_records", + "lambda_handler", + "max_actions_per_run", + "max_records_per_run", + "reconcile_documents", + "stuck_min_age_minutes", +] diff --git a/backend/src/apis/app_api/kb_upgrade/models.py b/backend/src/apis/app_api/kb_upgrade/models.py index 5fffa8cfe..61a0e9eef 100644 --- a/backend/src/apis/app_api/kb_upgrade/models.py +++ b/backend/src/apis/app_api/kb_upgrade/models.py @@ -28,6 +28,13 @@ "being_removed", ] +#: The UI-facing engine name for the ``Managed``/``Classic`` badge (task 16.4, +#: HANDOFF §6). Deliberately NOT the record's internal ``retrievalEngine`` values +#: (``managed`` / ``s3vectors``): the client should render a friendly word and +#: never learn the storage engine's name. Absence-means-legacy collapses to +#: ``classic`` here, the same way it resolves to the legacy backend everywhere. +KbEngine = Literal["managed", "classic"] + class UpgradeProgress(BaseModel): """Non-blocking progress for the ``in_progress`` phase (Requirement 23.3).""" @@ -68,6 +75,12 @@ class UpgradeStatusResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) phase: UpgradePhase + #: The engine currently serving this knowledge base, for the badge (task + #: 16.4). Present on every phase — including ``none`` — because the badge is + #: engine visibility, not upgrade state, and must render on a settled legacy + #: knowledge base too. Defaults to ``classic``: an unread or absent record is + #: legacy, the same default the retrieval resolver applies. + engine: KbEngine = "classic" #: True only for an owner/editor looking at an ``available`` knowledge base. #: The client hides the control on this alone; the server re-checks on write, #: so a client that ignores it gains nothing (Requirement 23.7). diff --git a/backend/src/apis/app_api/kb_upgrade/service.py b/backend/src/apis/app_api/kb_upgrade/service.py index 2bb9de264..253b1ab62 100644 --- a/backend/src/apis/app_api/kb_upgrade/service.py +++ b/backend/src/apis/app_api/kb_upgrade/service.py @@ -279,12 +279,19 @@ async def get_upgrade_status( record = await asyncio.to_thread(r.get_kb_record, assistant_id, assistant_id) state = str((record or {}).get("migrationState") or "") + # The badge's engine, resolved from the SAME record every phase below reads, + # so a "Managed" badge can never sit next to a phase derived from a legacy + # record (task 16.4). Absent/legacy ⇒ "classic", matching the retrieval + # resolver's absence-means-legacy default. + engine = "managed" if (record and r.resolve_engine(record) == r.ENGINE_MANAGED) else "classic" + if record and r.resolve_engine(record) == r.ENGINE_MANAGED: # Already upgraded. The only thing owed is the one-time notice, and only # until it is dismissed — never a permanent badge (Requirement 23.4). pending = not record.get("upgradeNoticeDismissedAt") return UpgradeStatusResponse( phase="succeeded", + engine=engine, canUpgrade=False, noticePending=bool(pending and can_edit), progress=_progress_of(record), @@ -293,6 +300,7 @@ async def get_upgrade_status( if state in (r.SHADOW, r.VERIFY, r.PROMOTE): return UpgradeStatusResponse( phase="in_progress", + engine=engine, canUpgrade=False, progress=_progress_of(record or {}), ) @@ -302,19 +310,20 @@ async def get_upgrade_status( # editors; the phase itself is not hidden (Requirement 23.5). return UpgradeStatusResponse( phase="failed", + engine=engine, canUpgrade=can_edit, reason=_failure_reason(record or {}), progress=_progress_of(record or {}), ) if not (can_edit and migration_enabled()): - return UpgradeStatusResponse(phase="none", canUpgrade=False) + return UpgradeStatusResponse(phase="none", engine=engine, canUpgrade=False) items = await asyncio.to_thread(_document_items, assistant_id) if not items: # An empty knowledge base has nothing to carry across, so there is no # action to take and therefore nothing to show (Requirement 23.1). - return UpgradeStatusResponse(phase="none", canUpgrade=False) + return UpgradeStatusResponse(phase="none", engine=engine, canUpgrade=False) carried, stranded = _partition_documents(items) if not carried: @@ -323,12 +332,14 @@ async def get_upgrade_status( # what this owner needs to see (Requirement 21.3). return UpgradeStatusResponse( phase="none", + engine=engine, canUpgrade=False, documentsNotCarried=stranded, ) return UpgradeStatusResponse( phase="available", + engine=engine, canUpgrade=True, progress=UpgradeProgress(completed=0, total=carried, skipped=len(stranded)), documentsNotCarried=stranded, diff --git a/backend/src/apis/app_api/mcp_apps/routes.py b/backend/src/apis/app_api/mcp_apps/routes.py index 712d16f3b..1043e1a67 100644 --- a/backend/src/apis/app_api/mcp_apps/routes.py +++ b/backend/src/apis/app_api/mcp_apps/routes.py @@ -37,6 +37,10 @@ from apis.shared.auth.dependencies import get_current_user_from_session from apis.shared.auth.models import User from apis.shared.mcp_apps.card_store import get_app_card_store +from apis.shared.mcp_apps.error_envelope import ( + app_tool_error_body, + read_error_envelope, +) logger = logging.getLogger(__name__) @@ -118,6 +122,16 @@ async def proxy_call( except Exception: # noqa: BLE001 - upstream returned non-JSON raise HTTPException(status_code=502, detail="Bad upstream response") + # inference-api reports app-tool errors as 200 + an envelope, because + # AgentCore Runtime flattens any non-2xx it returns into a generic 424 + # and discards the message. Restore the real status here, before the + # card write below — an enveloped error is a failed call and must not + # persist a provenance card. + enveloped = read_error_envelope(payload) + if enveloped is not None: + message, status = enveloped + return JSONResponse(app_tool_error_body(message), status_code=status) + # Option A (PR #6): on success, persist a static provenance card so the # call survives a page reload (the broker is in-memory; the live thread # event is otherwise lost on refresh). Best-effort + provenance-only — @@ -140,9 +154,11 @@ async def proxy_call( "mcp-apps: failed to persist provenance card", exc_info=True ) - # Relay inference-api's status verbatim (403 not-app-visible, 409 no - # live client, 502 tool failure, 200 success) so the bridge can answer - # the iframe's JSON-RPC with the right error. + # Relay inference-api's status verbatim so the bridge can answer the + # iframe's JSON-RPC with the right error. Reached only for a success + # (200) or for an error raised *before* the app-tool handler — anything + # that handler reports arrives as a 200 + envelope and returned above, + # because AgentCore Runtime would otherwise flatten its status to 424. return JSONResponse(payload, status_code=response.status_code) @@ -225,6 +241,12 @@ async def update_context( except Exception: # noqa: BLE001 - upstream returned non-JSON raise HTTPException(status_code=502, detail="Bad upstream response") + # Same AgentCore flattening as proxy-call; restore the real status. + enveloped = read_error_envelope(payload) + if enveloped is not None: + message, status = enveloped + return JSONResponse(app_tool_error_body(message), status_code=status) + return JSONResponse(payload, status_code=response.status_code) diff --git a/backend/src/apis/inference_api/chat/app_tool_dispatch.py b/backend/src/apis/inference_api/chat/app_tool_dispatch.py index b5a8a894f..ce5ac63f3 100644 --- a/backend/src/apis/inference_api/chat/app_tool_dispatch.py +++ b/backend/src/apis/inference_api/chat/app_tool_dispatch.py @@ -6,8 +6,14 @@ single tool call WITHOUT a model turn: 1. Rebuild the conversation's agent via `get_agent` (the same path resume - uses) so the MCP client session + auth (OAuth token cache, SigV4, - consent hook) are wired exactly as for a model-driven tool call. + uses) so the MCP client session + transport auth (SigV4, OIDC + forwarding, the lazy OAuth token provider) are wired exactly as for a + model-driven tool call. +1a. Resolve the OAuth token this call needs. A model-driven call gets this + from `OAuthConsentHook`, which fires on `BeforeToolCallEvent` — an event + this path never raises, because it calls the MCP client directly instead + of running the agent's tool loop. So the warm-the-cache half of the hook + is repeated here explicitly (see `_ensure_oauth_token`). 2. Re-check the tool's `_meta.ui.visibility` includes `"app"` — the spec MUST, enforced here as the second gate (app-api is the first). 3. Call the tool against the MCP client that surfaced it (recorded in the @@ -31,6 +37,7 @@ from typing import Any, Dict, List, Optional from apis.shared.mcp_apps.broker import get_app_tool_event_broker +from apis.shared.oauth.auth_failure import looks_like_auth_failure from apis.shared.security.log_sanitize import scrub_log @@ -40,8 +47,13 @@ class AppToolCallError(Exception): """Dispatch failed in a way the caller should surface as an error. - `code` is an app-api HTTP status hint; `message` is safe to return to - the client (no internals). + `code` is the status app-api should ultimately answer the SPA with, NOT + the status this container returns — AgentCore Runtime flattens any + non-2xx into a 424 and drops the body. The route encodes `code` and + `message` into a 200 response body and app-api restores the status. + See `apis/shared/mcp_apps/error_envelope.py`. + + `message` is safe to return to the client (no internals). """ def __init__(self, message: str, code: int = 400) -> None: @@ -180,6 +192,264 @@ def _resolve_client(agent: Any, tool_name: str): return ui_metadata, client +def _provider_for_client(client: Any) -> Optional[str]: + """The OAuth provider_id backing `client`, or None when it isn't gated. + + Reads the same `MCPClient -> provider_id` map `OAuthConsentHook` reaches + through its injected `provider_lookup`. Lazy import for the same reason + `_resolve_client` uses one. + """ + from agents.main_agent.integrations.external_mcp_client import ( + get_external_mcp_integration, + ) + + try: + return get_external_mcp_integration().provider_for_client(client) + except Exception: # noqa: BLE001 - a lookup miss must not block the call + logger.warning( + "failed to resolve the OAuth provider for an MCP client", + exc_info=True, + ) + return None + + +async def _user_disconnected(user_id: str, provider_id: str) -> bool: + """Durable "user pressed Disconnect" intent for (user, provider). + + Mirrors `OAuthConsentHook._is_disconnected`. Without it an App frame + left open on screen keeps working off the cached token for the rest of + its TTL after the user disconnects the connector. + """ + from apis.shared.oauth.disconnect_repository import get_disconnect_repository + + try: + return bool( + await get_disconnect_repository().is_disconnected(user_id, provider_id) + ) + except Exception: # noqa: BLE001 - fail open, same as the hook's lookup + logger.warning( + "failed to read disconnect intent for provider=%s", + scrub_log(provider_id), + exc_info=True, + ) + return False + + +async def _ensure_oauth_token(client: Any, user_id: str) -> Optional[str]: + """Guarantee an OAuth token is cached before an app-initiated call. + + A model-driven tool call gets this from `OAuthConsentHook._gate`, which + fires on `BeforeToolCallEvent`. This path calls the MCP client directly, + so that event never fires and nothing warms `oauth_token_cache` — the + lazy token provider then resolves to `None` and the request goes out + with no `Authorization` header at all. Servers that allow an + unauthenticated `tools/list` (so the tool still registers and the App + still renders) answer such a call with their own "you aren't connected" + text, which reads to the user as the App being broken. + + The cache is per-process, so it is cold on any container that has not + run a model-driven turn for this (user, provider) — the common case + after a page reload lands the call on a fresh runtime — and it expires + on its own TTL well before the App frame does. + + Returns the provider_id when the client is OAuth-gated (whether or not + the cache was already warm), else None. Raises `AppToolCallError` when + AgentCore Identity says this user genuinely still has to consent. + """ + provider_id = _provider_for_client(client) + if not provider_id: + return None + + from agents.main_agent.integrations import oauth_token_cache + from apis.shared.oauth.token_resolution import resolve_token_or_consent_url + + force_reauth = await _user_disconnected(user_id, provider_id) + if not force_reauth and oauth_token_cache.get(user_id, provider_id): + return provider_id + if force_reauth: + oauth_token_cache.clear_user_provider(user_id, provider_id) + + resolved = await resolve_token_or_consent_url( + provider_id, user_id, force_authentication=force_reauth + ) + if resolved is None: + # Couldn't ask AgentCore at all — that is not evidence of a consent + # gap, so don't tell the user to connect something they may already + # have connected. Let the call go out; the server's own error is a + # truer report than a guess. + logger.warning( + "could not resolve a %s token for an app-initiated tools/call; " + "calling unauthenticated", + scrub_log(provider_id), + ) + return provider_id + + if resolved["token"]: + oauth_token_cache.set(user_id, provider_id, resolved["token"]) + return provider_id + + # No token and a consent URL: the user has not authorized this + # connector. There is no turn to interrupt here, so surface it as an + # error the App can render. + # + # `code` is NOT this response's HTTP status. AgentCore Runtime rewrites + # any non-2xx from this container into a generic 424 and discards the + # body, so returning 409 directly reached the SPA as "Received error + # (409) from runtime. Please check your CloudWatch logs" — verified + # live on dev 2026-09-08. The route returns 200 + an envelope carrying + # this code and message, and app-api restores the real status before + # replying to the SPA. See `apis/shared/mcp_apps/error_envelope.py`. + # + # 409, never 401: the SPA's error interceptor treats *any* 401 as an + # expired BFF session and redirects to login, so answering "connect + # your account" with a 401 would sign the user out. 409 is already this + # codebase's "connector needs connecting" status (the file-source + # browser and the export dialog both branch on it to show Connect). + # The envelope reader enforces this independently — it will not relay + # a 401 even if one is asked for here. + raise AppToolCallError( + f"Authorization required for '{provider_id}'. Connect the account, " + "then try again.", + code=409, + ) + + +def _invalidate_oauth_token(user_id: str, provider_id: str, tool_name: str) -> None: + """Drop a token the MCP server just rejected, so the next call re-asks. + + Deliberately does NOT retry the call, unlike the hook's + `_handle_auth_failure`. An app-initiated call is whatever button the + user pressed — `complete_task`, `delete_event` — and re-firing a + mutation off a regex match could apply the side effect twice. Clearing + is enough: the next press misses the cache, re-resolves from the vault + (which refreshes transparently), and either succeeds or reports that + consent is required. + """ + from agents.main_agent.integrations import oauth_token_cache + + logger.info( + "app-initiated tools/call for tool=%s looks like a %s auth failure; " + "clearing the cached token so the next call re-resolves it", + scrub_log(tool_name), + scrub_log(provider_id), + ) + oauth_token_cache.clear_user_provider(user_id, provider_id) + + +# --- opportunistic UI-resource revalidation --------------------------------- +# The App HTML the SPA re-mounts on reload is whatever `resources/read` +# returned when the tool first ran, replayed verbatim from the `UIRES#` row. So +# a server that ships a new App version — or tightens the CSP its App runs +# under — never reaches conversations that already exist. +# +# Re-reading needs a live MCP client, and the only path to one is a built +# agent, so revalidating on every conversation open would add a full agent +# rebuild (76% of sessions bypass the agent cache) to a page load that runs no +# model turn. Instead we piggyback: an app-initiated tools/call ALREADY built +# the agent and revived the client, so the read is nearly free here. The +# refreshed shell lands on the NEXT load rather than this one — that is the +# trade, and it converges for the Apps people actually use. +_refresh_lock = threading.Lock() +_refreshed_resources: set = set() +_refresh_tasks: set = set() +# One refresh per resource per process; a chatty App must not re-read its own +# shell on every button press. +_MAX_REFRESHED = 512 + + +def _claim_refresh(session_id: str, tool_use_id: str) -> bool: + """Whether this process should refresh this resource (once only).""" + key = f"{session_id}#{tool_use_id}" + with _refresh_lock: + if key in _refreshed_resources: + return False + if len(_refreshed_resources) >= _MAX_REFRESHED: + _refreshed_resources.clear() + _refreshed_resources.add(key) + return True + + +def _refresh_ui_resource(user_id: str, session_id: str, tool_use_id: str) -> None: + """Re-read this App's `ui://` resource and overwrite its stored copy. + + Best-effort and silent: this runs after the App already has its answer, + so nothing here may raise or slow the call down. + """ + from apis.shared.mcp_apps.ui_resource_store import get_ui_resource_store + + store = get_ui_resource_store() + provenance = store.get_provenance(user_id=user_id, tool_use_id=tool_use_id) + if not provenance: + return + # The tool that PRODUCED the frame, which is not the tool the App just + # called — the resourceUri hangs off the producing tool's catalog entry. + producing_tool = provenance.get("toolName") + if not producing_tool: + return + + from agents.main_agent.integrations.mcp_apps import fetch_ui_resource + + _, client = _resolve_client(None, producing_tool) + if client is None: + return + # `fetch_ui_resource` calls `read_resource_sync` straight through, and + # between turns Strands has already stopped the client's session — the + # same reason an app-initiated call needs this wrapper. + with _active_session(client): + payload = fetch_ui_resource(producing_tool, tool_use_id) + if not payload or not payload.get("html"): + return + + store.store( + user_id=user_id, + session_id=session_id, + tool_use_id=tool_use_id, + resource_uri=payload.get("resourceUri", ""), + html=payload["html"], + mime_type=payload.get("mimeType", ""), + csp=payload.get("csp") or {}, + permissions=payload.get("permissions") or {}, + sandbox_origin=payload.get("sandboxOrigin", ""), + server_name=payload.get("serverName", ""), + icon=payload.get("icon", ""), + tool_name=producing_tool, + # Preserved: the anchor belongs to the producing turn, and a refresh + # must not renumber where the frame sits in the thread. + produced_by_message_index=provenance.get("producedByMessageIndex"), + ) + logger.info( + "mcp-apps: revalidated UI resource (session=%s, toolUseId=%s)", + scrub_log(session_id), + scrub_log(tool_use_id), + ) + + +def _schedule_ui_resource_refresh( + user_id: str, session_id: str, tool_use_id: str +) -> None: + """Fire the refresh off the response path; never fail the tool call.""" + if not _claim_refresh(session_id, tool_use_id): + return + + async def _run() -> None: + try: + await asyncio.to_thread( + _refresh_ui_resource, user_id, session_id, tool_use_id + ) + except Exception: # noqa: BLE001 - revalidation is best-effort + logger.warning( + "mcp-apps: UI resource revalidation failed (session=%s)", + scrub_log(session_id), + exc_info=True, + ) + + task = asyncio.create_task(_run()) + # Hold a reference: asyncio only weakly references running tasks, so + # without this the refresh can be garbage-collected mid-flight. + _refresh_tasks.add(task) + task.add_done_callback(_refresh_tasks.discard) + + async def dispatch_app_tool_call( agent: Any, *, @@ -210,6 +480,10 @@ async def dispatch_app_tool_call( f"No live MCP client for tool '{tool_name}'", code=409 ) + # The consent hook can't run for this call (no BeforeToolCallEvent), so + # resolve the OAuth token here or the request goes out unauthenticated. + provider_id = await _ensure_oauth_token(client, user_id) + # Distinct id for the thread card — the originating tool_use_id is the # one that rendered the iframe; this proxied call is its own invocation. synth_id = f"app-{tool_use_id}-{uuid.uuid4().hex[:8]}" @@ -238,6 +512,11 @@ def _invoke() -> Any: is_error = _is_error(result) status = "error" if is_error else "success" + if provider_id and looks_like_auth_failure( + {"status": status, "content": content} + ): + _invalidate_oauth_token(user_id, provider_id, tool_name) + # Surface the call in the conversation thread. Best-effort: a missing # listener (no active stream) buffers in the broker for the next turn; # never blocks returning the result to the App. @@ -270,6 +549,10 @@ def _invoke() -> Any: }, ) + # The agent is built and the client revived right now — the one moment + # re-reading this App's shell costs almost nothing. See the note above. + _schedule_ui_resource_refresh(user_id, session_id, tool_use_id) + return { "toolUseId": tool_use_id, "result": {"content": content, "isError": is_error}, diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index c93315a1d..1f440008b 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -67,6 +67,8 @@ dispatch_app_context_update, merge_and_clear_pending_context, ) +from apis.shared.mcp_apps.error_envelope import app_tool_error_response + from .app_tool_dispatch import AppToolCallError, dispatch_app_tool_call from .agent_binding_policy import binds_conversation from .models import FileContent, InvocationRequest @@ -1338,7 +1340,12 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g ) return JSONResponse(payload) except AppToolCallError as e: - return JSONResponse({"error": e.message}, status_code=e.code) + # 200 + envelope, not `status_code=e.code`: AgentCore Runtime + # rewrites any non-2xx to a generic 424 and discards the + # message, so a deliberate 409 ("connect the account") reached + # the SPA as "check your CloudWatch logs". app-api restores the + # real status. See `mcp_apps.error_envelope`. + return app_tool_error_response(e.message, e.code) except HTTPException: raise except Exception: @@ -1386,7 +1393,8 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g ) return JSONResponse(payload) except AppContextUpdateError as e: - return JSONResponse({"error": e.message}, status_code=e.code) + # Same AgentCore flattening as the app_tool_call path above. + return app_tool_error_response(e.message, e.code) except HTTPException: raise except Exception: @@ -1709,6 +1717,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g from apis.shared.assistants.kb_access import granted from apis.shared.assistants.rag_service import ( augment_prompt_with_context, + resolve_context_cap, search_assistant_knowledgebase_with_formatting, ) from apis.shared.assistants.service import ( @@ -1989,7 +1998,11 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # 4. Augment message with context if context_chunks: - augmented_message = augment_prompt_with_context(user_message=input_data.message, context_chunks=context_chunks) + # Engine-aware cap (Requirement 3.2): managed gets 8,000 so + # reranking's top_k chunks actually reach the model; legacy keeps + # 2,000. See rag_service.resolve_context_cap / HANDOFF §5.40. + cap = resolve_context_cap(input_data.rag_assistant_id) + augmented_message = augment_prompt_with_context(user_message=input_data.message, context_chunks=context_chunks, max_context_length=cap) logger.info( f"Augmented message with {len(context_chunks)} context chunks" ) diff --git a/backend/src/apis/shared/assistants/__init__.py b/backend/src/apis/shared/assistants/__init__.py index 0f48bb387..31a273d2a 100644 --- a/backend/src/apis/shared/assistants/__init__.py +++ b/backend/src/apis/shared/assistants/__init__.py @@ -38,6 +38,7 @@ ) from .rag_service import ( augment_prompt_with_context, + resolve_context_cap, search_assistant_knowledgebase_with_formatting, ) @@ -74,5 +75,6 @@ "update_share_permission", # RAG service functions "augment_prompt_with_context", + "resolve_context_cap", "search_assistant_knowledgebase_with_formatting", ] diff --git a/backend/src/apis/shared/assistants/rag_service.py b/backend/src/apis/shared/assistants/rag_service.py index 9e39ff763..772f4e194 100644 --- a/backend/src/apis/shared/assistants/rag_service.py +++ b/backend/src/apis/shared/assistants/rag_service.py @@ -22,10 +22,19 @@ * **``top_k`` narrowing.** Applied *after* the status filter, which is the order the legacy path has always used: filter-then-slice, so an incomplete document cannot silently shrink a five-chunk answer. -* **The 2,000-character context cap.** ``augment_prompt_with_context``'s - default. Held constant deliberately: the evaluation measured no correctness - change between 2,000 and 20,000 characters, so raising it here would add a - variable to a change whose whole purpose is to hold every variable but one. +* **The context cap, per engine.** ``augment_prompt_with_context`` takes an + explicit ``max_context_length``; :func:`resolve_context_cap` decides it from + the assistant's engine. Legacy keeps the historical 2,000 characters; managed + gets :data:`MANAGED_MAX_CONTEXT_CHARS` (8,000). This is a *deliberate* + managed-only asymmetry, amended into Requirement 3.2 on 2026-09-04 after + measurement: holding the *character* cap identical across backends did **not** + hold retrieval identical, because Bedrock's chunks are ~3x the size of the + Docling chunks the 2,000 figure was sized for — so at 2,000, four of managed's + five reranked chunks never reached the model and answers went wrong (HANDOFF + §5.40). Capping per engine restores parity in the unit that actually matters: + chunks reaching the model, not characters. The evaluation's §13.6 "no + correctness change 2,000→20,000" covered single-fact lookups only and flagged + multi-chunk synthesis — the case that broke — as untested. The dual-read pilot ------------------- @@ -45,7 +54,7 @@ import logging import os import time -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Mapping, Optional, Set import boto3 @@ -59,16 +68,50 @@ ) from apis.shared.kb_backend.protocol import DEFAULT_TOP_K, Chunk, distance_from_relevance from apis.shared.kb_backend.query_guard import clamp_query -from apis.shared.kb_backend.resolver import load_record, resolve_backend +from apis.shared.kb_backend.resolver import ( + ENGINE_MANAGED, + load_record, + resolve_backend, + resolve_engine_for, +) logger = logging.getLogger(__name__) -#: Parity contract (Requirement 3.2): the cap is 2,000 characters on every -#: backend, unchanged from the value the legacy path has always used. Named so -#: that a change to it is a visible change to a constant rather than an edit to a -#: default argument. +#: Legacy context cap (Requirement 3.2): 2,000 characters, unchanged from the +#: value the S3 Vectors path has always used. Named so a change to it is a visible +#: change to a constant rather than an edit to a default argument. MAX_CONTEXT_CHARS = 2000 +#: Managed context cap (Requirement 3.2, amended by measurement 2026-09-04). +#: Bedrock's chunks run ~3x larger than the Docling chunks 2,000 was sized for, so +#: at 2,000 only ~1 of top_k=5 managed chunks clears the cap and four of +#: reranking's results never reach the model — measured, with wrong/degraded +#: answers to show for it (HANDOFF §5.40). 8,000 is the evaluation's own §13.6 +#: sizing: the point at which all five managed chunks fit, ~966 extra input +#: tokens/turn. Pin the literal, not ``MAX_CONTEXT_CHARS * k``: 8,000 is a +#: property of Bedrock's chunk sizing measured against this corpus, not a multiple +#: of the legacy figure, and must not silently follow it if that one moves. +MANAGED_MAX_CONTEXT_CHARS = 8000 + + +def resolve_context_cap(assistant_id: str, *, record: Optional[Mapping[str, Any]] = None) -> int: + """The context-character cap for this assistant's engine, read at call time. + + Managed knowledge bases get :data:`MANAGED_MAX_CONTEXT_CHARS`; every other + engine — and any unreadable record, which resolves to legacy — gets + :data:`MAX_CONTEXT_CHARS`. Keyed on the SAME decision the backend resolver + uses (:func:`resolve_engine_for`, backed by ``records.resolve_engine``), so + the cap and the backend can never disagree about which engine an assistant is + on. That single source is the whole point: a second, independent "is this + managed?" test here would be a second chance to drift. + + Pass ``record`` when the caller already holds the KB_Record to skip a read. + The constants are read inside the function, at call time, never bound as + default arguments — see the module-constant note in HANDOFF §3. + """ + engine = resolve_engine_for(assistant_id, record=record) + return MANAGED_MAX_CONTEXT_CHARS if engine == ENGINE_MANAGED else MAX_CONTEXT_CHARS + async def search_assistant_knowledgebase_with_formatting( assistant_id: str, @@ -137,6 +180,21 @@ async def search_assistant_knowledgebase_with_formatting( record = load_record(assistant_id) backend = resolve_backend(assistant_id, record=record) + # Engine visibility (task 16.4, HANDOFF §6): exactly one INFO line per + # query naming the engine that served it. The resolver logs only on + # failure, so before this the question "is the managed backend actually + # serving?" could be answered only by reading the KB_Record out of band — + # and this feature's whole risk profile is silent regressions. Read from + # the SAME record ``resolve_backend`` just used (no extra DynamoDB round + # trip), so the logged engine can never disagree with the one that ran. + engine = resolve_engine_for(assistant_id, record=record) + logger.info( + "knowledge base retrieval for assistant %s served by engine=%s (%s)", + assistant_id, + engine, + "Managed" if engine == ENGINE_MANAGED else "Classic", + ) + # Clamp before dispatch, so both backends receive an identically-shaped # query (Requirement 4.2). Managed KB rejects anything over 10,000 # characters outright and the quota is not adjustable, so clamping only @@ -265,7 +323,22 @@ def _filter_vectors_by_document_status(vectors: List[Dict[str, Any]], assistant_ doc_ids.add(doc_id) if not doc_ids: - return vectors + # FAIL CLOSED (Requirement 5; §5.33). Reaching here with vectors present + # means not one chunk carried a `document_id`, so not one can be confirmed + # `complete` — the same unprovable state the branches below drop to `[]`. + # The old `return vectors` was the single fail-OPEN line left in an + # otherwise fail-closed function: it served chunks whose parent document + # was never verified (including content a user may have deleted) whenever + # `_document_id` resolved to "" for the whole batch. An *empty* input stays + # an empty result with no metric — that is an ordinary "no match", logged + # at INFO by the caller, not a degradation. + if vectors: + logger.error( + "Document status filter: chunks present but none carry a " + "document_id; dropping all because status cannot be confirmed" + ) + emit_count(METRIC_STATUS_FILTER_FAIL_CLOSED) + return [] # Look up document status in DynamoDB valid_doc_ids: Set[str] = set() diff --git a/backend/src/apis/shared/mcp_apps/error_envelope.py b/backend/src/apis/shared/mcp_apps/error_envelope.py new file mode 100644 index 000000000..498f06688 --- /dev/null +++ b/backend/src/apis/shared/mcp_apps/error_envelope.py @@ -0,0 +1,122 @@ +"""Carry an app-tool error across the AgentCore Runtime boundary. + +inference-api runs behind AgentCore Runtime, which rewrites **any** non-2xx +container response to a generic ``424``:: + + {"message": "Received error (409) from runtime. Please check your + CloudWatch logs for more information."} + +Both the status *and* the human-readable message are destroyed. So an +app-initiated `tools/call` that needs OAuth consent — which +`dispatch_app_tool_call` reports as a deliberate 409 carrying "Connect the +account, then try again" — reached the SPA as a 424 whose body told the user +to go read CloudWatch logs. Verified live on dev 2026-09-08. + +The fix: inference-api answers **200** with the error in the body instead, +and app-api translates it back to the real HTTP status before replying to +the SPA. The SPA's contract is unchanged — it still sees 403 / 409 / 502 +with ``{"error": ""}`` — so only the one hop that crosses AgentCore +changes shape. + +**Never widen `_RELAYABLE_STATUSES` to include 401.** The SPA's +`error.interceptor` treats any 401 as an expired BFF session and redirects +to login, so relaying a 401 here would sign the user out over an unconnected +connector. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple + +if TYPE_CHECKING: # pragma: no cover - import cycle guard + from fastapi.responses import JSONResponse + +# The key is deliberately distinct from the plain ``error`` key a direct +# (non-AgentCore) inference-api response uses, so app-api can tell an +# enveloped error from an ordinary error body without guessing. +ENVELOPE_KEY = "appToolError" + +# Statuses app-api will re-emit from an envelope. An upstream that names +# anything else collapses to 502 rather than letting a malformed payload +# choose app-api's status code. +_RELAYABLE_STATUSES = frozenset({400, 403, 404, 409, 422, 502}) + +_FALLBACK_STATUS = 502 +_FALLBACK_MESSAGE = "Tool call failed" + + +def build_error_envelope(message: str, code: int) -> Dict[str, Any]: + """Body for a 200 response that actually reports an error. + + Returned by inference-api in place of ``JSONResponse({...}, code)``, + which AgentCore would flatten. + """ + return {ENVELOPE_KEY: {"code": int(code), "message": str(message)}} + + +def app_tool_error_response(message: str, code: int) -> "JSONResponse": + """inference-api's reply for a failed app-tool call: **200** + envelope. + + The 200 is the whole point — returning `code` directly is what AgentCore + flattens into a 424. Use this instead of `JSONResponse({...}, code)` in + any inference-api handler whose response crosses the Runtime boundary. + """ + from fastapi.responses import JSONResponse + + return JSONResponse(build_error_envelope(message, code), status_code=200) + + +def app_tool_error_body(message: str) -> Dict[str, str]: + """app-api's error body for a failed app-tool call. + + Carries the same text under **both** keys on purpose, because two + independent consumers read it and they disagree on the key: + + - ``error`` — `McpAppProxyService` reads ``err.error?.error`` and hands + the text to the iframe's JSON-RPC reply. + - ``detail`` — the SPA's global `ErrorService` renders the toast. It + reads ``detail`` / ``error.detail`` / ``error.message`` / ``message``, + and treats a *string* ``error`` as no message at all — falling back to + a generic per-status string ("The request conflicts with the current + state." for 409). ``detail`` is also FastAPI's own `HTTPException` + shape, which the rest of this router already emits. + + Verified on dev 2026-09-09: with only ``error``, a consent failure + surfaced as the generic 409 toast even though the message was present + in the body. + """ + return {"error": message, "detail": message} + + +def read_error_envelope( + payload: Any, +) -> Optional[Tuple[str, int]]: + """Return ``(message, status)`` if `payload` carries an envelope. + + ``None`` means the payload is an ordinary result and the caller should + relay it unchanged. A malformed or unlisted status falls back to 502 so + a bad upstream can never pick app-api's status code — in particular it + can never produce a 401. + """ + if not isinstance(payload, dict): + return None + envelope = payload.get(ENVELOPE_KEY) + if not isinstance(envelope, dict): + return None + + raw_message = envelope.get("message") + message = ( + raw_message.strip() + if isinstance(raw_message, str) and raw_message.strip() + else _FALLBACK_MESSAGE + ) + + raw_code = envelope.get("code") + try: + code = int(raw_code) + except (TypeError, ValueError): + code = _FALLBACK_STATUS + if code not in _RELAYABLE_STATUSES: + code = _FALLBACK_STATUS + + return message, code diff --git a/backend/src/apis/shared/mcp_apps/ui_resource_store.py b/backend/src/apis/shared/mcp_apps/ui_resource_store.py index e422405df..7f5a7b0cc 100644 --- a/backend/src/apis/shared/mcp_apps/ui_resource_store.py +++ b/backend/src/apis/shared/mcp_apps/ui_resource_store.py @@ -236,6 +236,39 @@ def store( exc_info=True, ) + def get_provenance( + self, *, user_id: str, tool_use_id: str + ) -> Optional[Dict[str, Any]]: + """The producing tool name + message anchor for one persisted row. + + Deliberately projects only the two attributes a revalidation needs + and NOT `htmlGz` — the caller is about to replace the HTML anyway, + and pulling a ~130KB blob back just to overwrite it would make the + opportunistic refresh cost more than the read it saves. + + Returns None when the row is absent, the table isn't configured, or + the read fails; every caller treats that as "nothing to refresh". + """ + if self._table is None: + return None + try: + resp = self._table.get_item( + Key={"PK": f"USER#{user_id}", "SK": f"UIRES#{tool_use_id}"}, + ProjectionExpression="toolName, producedByMessageIndex", + ) + except Exception: # noqa: BLE001 - refresh is best-effort + logger.warning( + "mcp-apps ui-resource store: provenance read failed " + "(toolUseId=%s)", + tool_use_id, + exc_info=True, + ) + return None + item = resp.get("Item") + if not item: + return None + return _decimal_to_native(item) + def list_for_session( self, *, session_id: str, user_id: str ) -> List[Dict[str, Any]]: diff --git a/backend/src/apis/shared/oauth/auth_failure.py b/backend/src/apis/shared/oauth/auth_failure.py new file mode 100644 index 000000000..a0e1ff917 --- /dev/null +++ b/backend/src/apis/shared/oauth/auth_failure.py @@ -0,0 +1,74 @@ +"""Shared "does this tool result look like an OAuth 401?" heuristic. + +Two paths need the same answer about an MCP tool result: + + * ``OAuthConsentHook._handle_auth_failure`` — the model-driven tool + loop, which clears the token cache and retries the call. + * ``dispatch_app_tool_call`` — the app-initiated ``tools/call`` path, + which clears the token cache but deliberately does NOT retry (an App + call can be a mutation, so a heuristic-driven retry could apply a side + effect twice). + +Keeping the markers in one place means the two paths can never drift into +disagreeing about what an auth failure looks like. +""" + +from __future__ import annotations + +import re +from typing import Any + +# Markers that indicate an OAuth-style auth failure in a tool result. +# A false positive triggers an unnecessary OAuth popup — far more +# disruptive than a missed match (which surfaces the underlying error to +# the user). So we err on the side of high-confidence signals only. +# +# Tiers: +# 1. HTTP 401 with negative lookarounds for path segments / adjacent +# digits. Bare "401" in MCP error text is almost always an HTTP +# status code in practice. +# 2. "Unauthorized" only when paired with an HTTP/status/code keyword. +# The bare word fires on prose like "you are not authorized to view +# this calendar" — which is application-level, not OAuth. +# 3. Unambiguous OAuth/token signals stand alone — `invalid_token`, +# `invalid_grant` (refresh-token revocation), Google API's +# `UNAUTHENTICATED` and `invalid authentication credentials`. +# +# We only run this on results whose `status == "error"` +# (see `looks_like_auth_failure`), so even the broader patterns above +# are gated by an explicit failure signal from the MCP framework. +AUTH_FAILURE_PATTERN = re.compile( + r"(? bool: + """Heuristic: does this tool result look like an OAuth 401? + + Inspects the result's status and content for one of the markers above. + False positives here just trigger a wasted retry; false negatives + leave the user stuck with a stale token, so we err on the side of + matching. + """ + if not isinstance(tool_result, dict): + return False + if tool_result.get("status") != "error": + return False + for block in tool_result.get("content", []) or []: + if not isinstance(block, dict): + continue + text = block.get("text") or "" + if isinstance(text, str) and AUTH_FAILURE_PATTERN.search(text): + return True + return False diff --git a/backend/tests/agents/builtin_tools/browser/__init__.py b/backend/tests/agents/builtin_tools/browser/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/agents/builtin_tools/browser/test_browse_tool.py b/backend/tests/agents/builtin_tools/browser/test_browse_tool.py new file mode 100644 index 000000000..93ff89083 --- /dev/null +++ b/backend/tests/agents/builtin_tools/browser/test_browse_tool.py @@ -0,0 +1,381 @@ +"""Tests for the AgentCore Browser tool. + +No AWS: the CDP layer is exercised against a fake websocket that speaks the +protocol, and the tool layer against a fake CDP session. What these protect is +the framing (ids, sessionId routing, error surfacing) and the output budgets — +the two things a live smoke test is least likely to catch, because a happy-path +browse looks fine right up until a page returns 400KB of text. +""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import pytest + +from agents.builtin_tools.browser import browse_tool +from agents.builtin_tools.browser.browse_tool import browse_web +from agents.builtin_tools.browser.cdp_client import CdpError, CdpSession + + +def _call(tool, **kwargs): + """Reach the underlying callable inside the Strands tool wrapper.""" + inner = getattr(tool, "_tool_func", None) or getattr(tool, "func", None) or tool + if hasattr(inner, "__wrapped__"): + inner = inner.__wrapped__ + return inner(**kwargs) + + +# -------------------------------------------------------------------------- +# CDP framing +# -------------------------------------------------------------------------- + + +class FakeWebSocket: + """Answers CDP commands from a scripted table.""" + + def __init__(self, responses: Dict[str, Any], *, page_targets: bool = True) -> None: + self._responses = responses + self._page_targets = page_targets + self.sent: List[dict] = [] + self._outbox: asyncio.Queue = asyncio.Queue() + self.closed = False + + async def send(self, raw: str) -> None: + message = json.loads(raw) + self.sent.append(message) + method = message["method"] + if method == "Target.getTargets": + infos = [{"targetId": "T1", "type": "page"}] if self._page_targets else [] + result = {"targetInfos": infos} + elif method == "Target.attachToTarget": + result = {"sessionId": "S1"} + elif method in self._responses: + entry = self._responses[method] + if isinstance(entry, Exception): + await self._outbox.put( + {"id": message["id"], "error": {"message": str(entry)}} + ) + return + result = entry + else: + result = {} + await self._outbox.put({"id": message["id"], "result": result}) + + def __aiter__(self): + return self + + async def __anext__(self): + item = await self._outbox.get() + return json.dumps(item) + + async def close(self) -> None: + self.closed = True + + +async def _session(ws: FakeWebSocket) -> CdpSession: + session = CdpSession(ws) + session._reader = asyncio.create_task(session._read_loop()) + await session._attach_to_page() + return session + + +@pytest.mark.asyncio +async def test_attaches_to_page_and_scopes_later_commands() -> None: + ws = FakeWebSocket({"Runtime.evaluate": {"result": {"value": "hello"}}}) + session = await _session(ws) + + value = await session.evaluate("1+1") + + assert value == "hello" + target_cmds = [m for m in ws.sent if m["method"].startswith("Target.")] + assert [m["method"] for m in target_cmds] == [ + "Target.getTargets", + "Target.attachToTarget", + ] + # Target.* must be browser-scoped, page commands session-scoped. + assert all("sessionId" not in m for m in target_cmds) + evaluate = [m for m in ws.sent if m["method"] == "Runtime.evaluate"][0] + assert evaluate["sessionId"] == "S1" + await session.close() + + +@pytest.mark.asyncio +async def test_creates_a_target_when_none_exists() -> None: + ws = FakeWebSocket({"Target.createTarget": {"targetId": "NEW"}}, page_targets=False) + session = await _session(ws) + + assert any(m["method"] == "Target.createTarget" for m in ws.sent) + await session.close() + + +@pytest.mark.asyncio +async def test_page_exception_surfaces_as_cdp_error() -> None: + ws = FakeWebSocket( + { + "Runtime.evaluate": { + "exceptionDetails": { + "exception": {"description": "ReferenceError: nope is not defined"} + } + } + } + ) + session = await _session(ws) + + with pytest.raises(CdpError, match="ReferenceError"): + await session.evaluate("nope()") + await session.close() + + +@pytest.mark.asyncio +async def test_protocol_error_surfaces_with_method_name() -> None: + ws = FakeWebSocket({"Page.navigate": RuntimeError("Cannot navigate to invalid URL")}) + session = await _session(ws) + + with pytest.raises(CdpError, match="Page.navigate failed"): + await session.command("Page.navigate", {"url": "::"}) + await session.close() + + +@pytest.mark.asyncio +async def test_close_is_idempotent_and_fails_pending_commands() -> None: + ws = FakeWebSocket({}) + session = await _session(ws) + + await session.close() + await session.close() # must not raise + + assert session.closed + with pytest.raises(CdpError, match="closed"): + await session.command("Runtime.evaluate") + + +# -------------------------------------------------------------------------- +# URL validation +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "file:///etc/passwd", + "http://169.254.169.254/latest/meta-data/", + "http://localhost:8000/admin", + "http://127.0.0.1/", + "ftp://example.com/x", + ], +) +def test_unsafe_urls_are_refused(url: str) -> None: + assert browse_tool._validate_url(url) is not None + + +@pytest.mark.parametrize("url", ["https://example.com", "http://example.com/a?b=c"]) +def test_public_urls_are_allowed(url: str) -> None: + assert browse_tool._validate_url(url) is None + + +# -------------------------------------------------------------------------- +# Tool dispatch and output budgets +# -------------------------------------------------------------------------- + + +@dataclass +class FakeCdp: + values: Dict[str, Any] = field(default_factory=dict) + navigated: List[str] = field(default_factory=list) + closed: bool = False + screenshot_data: str = "" + + async def navigate(self, url: str, **_: Any) -> None: + self.navigated.append(url) + + async def evaluate(self, expression: str, **_: Any) -> Any: + for needle, value in self.values.items(): + if needle in expression: + return value + return None + + async def screenshot(self) -> str: + return self.screenshot_data + + +@dataclass +class FakeLive: + cdp: FakeCdp + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class FakeState: + def __init__(self) -> None: + self._data: Dict[str, Any] = {} + + def get(self, key: str) -> Any: + return json.loads(json.dumps(self._data.get(key))) if key in self._data else None + + def set(self, key: str, value: Any) -> None: + self._data[key] = json.loads(json.dumps(value)) + + +class FakeAgent: + def __init__(self) -> None: + self.state = FakeState() + + +class FakeContext: + def __init__(self, agent: FakeAgent) -> None: + self.agent = agent + + +@pytest.fixture +def patched_pool(monkeypatch): + """Replace the session pool so no AWS call is attempted.""" + cdp = FakeCdp() + live = FakeLive(cdp=cdp) + + async def _acquire(_agent): + return live + + monkeypatch.setattr(browse_tool.session_pool, "acquire", _acquire) + return live + + +@pytest.mark.asyncio +async def test_navigate_returns_title_and_text(patched_pool) -> None: + patched_pool.cdp.values = { + "document.title": "Example Domain", + "location.href": "https://example.com/", + "querySelector('main')": "Body copy here.", + } + + result = await _call( + browse_web, + action="navigate", + url="https://example.com", + tool_context=FakeContext(FakeAgent()), + ) + + assert result["status"] == "success" + text = result["content"][0]["text"] + assert "Example Domain" in text + assert "Body copy here." in text + assert patched_pool.cdp.navigated == ["https://example.com"] + + +@pytest.mark.asyncio +async def test_navigate_refuses_metadata_url_without_touching_the_pool(monkeypatch) -> None: + called = False + + async def _acquire(_agent): + nonlocal called + called = True + raise AssertionError("pool must not be touched") + + monkeypatch.setattr(browse_tool.session_pool, "acquire", _acquire) + + result = await _call( + browse_web, + action="navigate", + url="http://169.254.169.254/latest/meta-data/", + tool_context=FakeContext(FakeAgent()), + ) + + assert result["status"] == "error" + assert not called + + +@pytest.mark.asyncio +async def test_page_text_is_truncated_to_the_budget(patched_pool, monkeypatch) -> None: + monkeypatch.setattr(browse_tool, "MAX_TEXT_CHARS", 100) + patched_pool.cdp.values = {"querySelector('main')": "x" * 5000} + + result = await _call( + browse_web, action="extract_text", tool_context=FakeContext(FakeAgent()) + ) + + text = result["content"][0]["text"] + assert len(text) < 400 + assert "truncated" in text + + +@pytest.mark.asyncio +async def test_click_reports_a_missing_selector_as_error(patched_pool) -> None: + patched_pool.cdp.values = {"querySelector": False} + + result = await _call( + browse_web, + action="click", + selector="#nope", + tool_context=FakeContext(FakeAgent()), + ) + + assert result["status"] == "error" + assert "#nope" in result["content"][0]["text"] + + +@pytest.mark.asyncio +async def test_type_requires_selector_and_text(patched_pool) -> None: + result = await _call( + browse_web, action="type", selector="#q", tool_context=FakeContext(FakeAgent()) + ) + assert result["status"] == "error" + + +@pytest.mark.asyncio +async def test_evaluate_renders_structured_results(patched_pool) -> None: + patched_pool.cdp.values = {"headings": ["One", "Two"]} + + result = await _call( + browse_web, + action="evaluate", + script="headings", + tool_context=FakeContext(FakeAgent()), + ) + + assert result["status"] == "success" + assert "One" in result["content"][0]["text"] + + +@pytest.mark.asyncio +async def test_screenshot_returns_image_bytes(patched_pool) -> None: + import base64 + + patched_pool.cdp.screenshot_data = base64.b64encode(b"PNGDATA").decode() + + result = await _call( + browse_web, action="screenshot", tool_context=FakeContext(FakeAgent()) + ) + + assert result["content"][1]["image"]["source"]["bytes"] == b"PNGDATA" + + +@pytest.mark.asyncio +async def test_unknown_action_lists_valid_actions(patched_pool) -> None: + result = await _call( + browse_web, action="teleport", tool_context=FakeContext(FakeAgent()) + ) + + assert result["status"] == "error" + assert "navigate" in result["content"][0]["text"] + + +@pytest.mark.asyncio +async def test_kill_switch_short_circuits(monkeypatch) -> None: + monkeypatch.setenv("BROWSER_TOOL_ENABLED", "false") + + async def _acquire(_agent): + raise AssertionError("pool must not be touched when disabled") + + monkeypatch.setattr(browse_tool.session_pool, "acquire", _acquire) + + result = await _call( + browse_web, + action="navigate", + url="https://example.com", + tool_context=FakeContext(FakeAgent()), + ) + + assert result["status"] == "error" + assert "disabled" in result["content"][0]["text"] diff --git a/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py b/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py index ad8255168..16d2caeee 100644 --- a/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py +++ b/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py @@ -8,7 +8,7 @@ the system prompt keep the stable prefix readable from cache on those turns. Contract under test (see ModelConfig.to_bedrock_config comment): - 1. toolConfig.tools tail — via cache_tools="default" + 1. toolConfig.tools tail — via CacheConfig(tools_ttl=True) 2. system tail — via SystemContentBlock list from AgentFactory 3. last user message tail — via CacheConfig(strategy="auto") Bedrock allows max 4 cachePoints per request; nothing else may add one, so the @@ -37,22 +37,41 @@ def _count_cache_points(node) -> int: # --------------------------------------------------------------------------- -# ModelConfig: cache_tools + support predicate +# ModelConfig: tools caching + support predicate # --------------------------------------------------------------------------- class TestCacheToolsConfig: - def test_cache_tools_set_for_claude_with_caching(self): + def test_tools_ttl_set_for_claude_with_caching(self): config = ModelConfig(model_id=CLAUDE_MODEL_ID, caching_enabled=True) - assert config.to_bedrock_config()["cache_tools"] == "default" + bedrock_config = config.to_bedrock_config() + assert bedrock_config["cache_config"].tools_ttl is True + # The model-level key was deprecated in strands-agents 1.55.0 + # (_warn_on_deprecated_cache_tools); tools_ttl supersedes it. + assert "cache_tools" not in bedrock_config - def test_no_cache_tools_when_caching_disabled(self): + def test_no_cache_config_when_caching_disabled(self): config = ModelConfig(model_id=CLAUDE_MODEL_ID, caching_enabled=False) - assert "cache_tools" not in config.to_bedrock_config() + bedrock_config = config.to_bedrock_config() + assert "cache_config" not in bedrock_config + assert "cache_tools" not in bedrock_config - def test_no_cache_tools_for_non_anthropic_bedrock_model(self): + def test_tools_ttl_off_for_non_anthropic_bedrock_model(self): """A model Strands' auto strategy would no-op on must not get explicit cachePoints either — Bedrock would reject them with ValidationException.""" config = ModelConfig(model_id="amazon.nova-pro-v1:0", caching_enabled=True) - assert "cache_tools" not in config.to_bedrock_config() + bedrock_config = config.to_bedrock_config() + assert bedrock_config["cache_config"].tools_ttl is False + assert "cache_tools" not in bedrock_config + + def test_no_ttl_configured_so_the_emitted_points_carry_none(self): + """cache_config.ttl must stay unset. + + It is what makes tools_ttl=True emit a bare ``{"type": "default"}`` + (byte-identical to the old cache_tools="default"), and what keeps + _apply_system_cache_ttl from rewriting the TTL on the cache point + AgentFactory places. Both sit inside the cached prefix. + """ + config = ModelConfig(model_id=CLAUDE_MODEL_ID, caching_enabled=True) + assert config.to_bedrock_config()["cache_config"].ttl is None def test_support_predicate_false_for_non_bedrock_provider(self): config = ModelConfig( @@ -187,6 +206,32 @@ def test_system_tail_is_cache_point(self, request_parts): # system point must survive. assert request_parts["system"][0] == {"text": "You are a helpful assistant."} + def test_system_blocks_hold_exactly_one_cache_point(self, request_parts): + """1.55's _should_cache_system must not double the point we placed. + + Its guard is ``not any("cachePoint" in block ...)``, so a second point + can only appear if AgentFactory stops placing ours — which would move + the system boundary and rewrite the cached prefix. A count of 2 would + also be a ValidationException (adjacent cache points). + """ + assert _count_cache_points(request_parts["system"]) == 1 + + def test_system_prompt_without_our_point_gets_exactly_one(self, model): + """The 1.55 safety net, for any path that bypasses AgentFactory. + + Pinned deliberately: cache_config.system_prompt_ttl stays at its + default True, so a system prompt reaching Bedrock without our + trailing cachePoint still ends the static prefix at the same place + rather than at the tools tail. + """ + request = model.format_request( + [{"role": "user", "content": [{"text": "hi"}]}], + None, + system_prompt_content=[{"text": "You are a helpful assistant."}], + ) + assert _count_cache_points(request["system"]) == 1 + assert request["system"][-1] == {"cachePoint": {"type": "default"}} + def test_last_user_message_tail_is_cache_point(self, request_parts): last_user = [m for m in request_parts["messages"] if m["role"] == "user"][-1] assert last_user["content"][-1] == {"cachePoint": {"type": "default"}} diff --git a/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py b/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py index d68406ad6..b7ac51573 100644 --- a/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py +++ b/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py @@ -19,10 +19,8 @@ TokenResult, WorkloadTokenUnavailableError, ) -from agents.main_agent.session.hooks.oauth_consent import ( - OAuthConsentHook, - _looks_like_auth_failure, -) +from apis.shared.oauth.auth_failure import looks_like_auth_failure +from agents.main_agent.session.hooks.oauth_consent import OAuthConsentHook @pytest.fixture(autouse=True) @@ -809,7 +807,7 @@ def _ok(self, text: str) -> dict: ], ) def test_matches_genuine_auth_errors(self, text): - assert _looks_like_auth_failure(self._err(text)) is True + assert looks_like_auth_failure(self._err(text)) is True @pytest.mark.parametrize( "text", @@ -842,22 +840,22 @@ def test_matches_genuine_auth_errors(self, text): ], ) def test_avoids_false_positives(self, text): - assert _looks_like_auth_failure(self._err(text)) is False + assert looks_like_auth_failure(self._err(text)) is False def test_ignores_non_error_status(self): # Even an auth-shaped body doesn't count if status is success. - assert _looks_like_auth_failure(self._ok("401 Unauthorized")) is False + assert looks_like_auth_failure(self._ok("401 Unauthorized")) is False def test_ignores_non_dict_result(self): - assert _looks_like_auth_failure("HTTP 401 Unauthorized") is False - assert _looks_like_auth_failure(None) is False - assert _looks_like_auth_failure(["401"]) is False + assert looks_like_auth_failure("HTTP 401 Unauthorized") is False + assert looks_like_auth_failure(None) is False + assert looks_like_auth_failure(["401"]) is False def test_ignores_missing_content(self): - assert _looks_like_auth_failure({"status": "error"}) is False - assert _looks_like_auth_failure({"status": "error", "content": None}) is False - assert _looks_like_auth_failure({"status": "error", "content": []}) is False + assert looks_like_auth_failure({"status": "error"}) is False + assert looks_like_auth_failure({"status": "error", "content": None}) is False + assert looks_like_auth_failure({"status": "error", "content": []}) is False def test_ignores_non_dict_content_blocks(self): result = {"status": "error", "content": ["401 Unauthorized"]} - assert _looks_like_auth_failure(result) is False + assert looks_like_auth_failure(result) is False diff --git a/backend/tests/agents/main_agent/streaming/test_cancellation_state.py b/backend/tests/agents/main_agent/streaming/test_cancellation_state.py index 338ee11f0..11f2874e0 100644 --- a/backend/tests/agents/main_agent/streaming/test_cancellation_state.py +++ b/backend/tests/agents/main_agent/streaming/test_cancellation_state.py @@ -152,10 +152,32 @@ def test_agent_exposes_cancel(self): assert callable(getattr(Agent, "cancel", None)) def test_sequential_executor_honors_the_cancel_signal(self): - """Queued tools must be skipped once cancel is armed (new in 1.51.0).""" + """Queued tools must be skipped once cancel is armed (new in 1.51.0). + + 1.51.0 read ``agent._cancel_signal`` inline; 1.55.0 moved the same read + behind ``Agent._observe_cancellation``. Accept either spelling — what + must not disappear is the per-tool check. + """ from strands.tools.executors import sequential - assert "_cancel_signal" in inspect.getsource(sequential) + source = inspect.getsource(sequential) + assert "_cancel_signal" in source or "_observe_cancellation" in source + + def test_observe_cancellation_reads_the_signal_we_clear(self): + """``reset_cancellation_state`` clears ``agent._cancel_signal`` by name. + + 1.55.0's ``_observe_cancellation`` is the only reader the executor goes + through, and it also mirrors a caller-supplied ``_external_cancel_signal`` + onto the internal one. We never pass ``cancel_signal`` to ``stream_async``, + so that stays None — but if a future change starts passing one, clearing + the internal signal alone would stop being enough and a cancelled turn + would wedge every turn after it. + """ + from strands import Agent + + source = inspect.getsource(Agent._observe_cancellation) + assert "self._cancel_signal.is_set()" in source + assert "_external_cancel_signal" in source def test_mcp_tool_forwards_the_cancel_signal(self): """The in-flight MCP call must see the signal (new in 1.51.0).""" diff --git a/backend/tests/agents/main_agent/test_voice_agent.py b/backend/tests/agents/main_agent/test_voice_agent.py index fcf162cf5..a67ecdf3b 100644 --- a/backend/tests/agents/main_agent/test_voice_agent.py +++ b/backend/tests/agents/main_agent/test_voice_agent.py @@ -21,6 +21,82 @@ def test_voice_agent_is_base_agent_subclass(self): assert issubclass(VoiceAgent, BaseAgent) +class TestBidiProviderContract: + """Bind the voice provider import against the pinned SDK. + + ``voice_agent`` imports the provider inside a ``try/except ImportError`` + that degrades to ``BIDI_AVAILABLE = False`` and one INFO line. A rename + upstream therefore does not crash — it silently turns voice off. That is + exactly what strands-agents 1.55.0 did: ``models.nova_sonic``'s + ``BidiNovaSonicModel`` became ``models.bedrock``'s + ``BedrockNovaSonicModel``. + + These assertions read the pinned SDK's *source*, not a live import, because + ``tests.yml`` installs ``--extra agentcore --extra dev`` but not + ``--extra bidi``: the provider module ships in the base wheel while its + runtime dependencies do not, so importing it here would fail on CI even + when the pin is correct. + """ + + def _provider_source(self): + import importlib.util + import pathlib + + spec = importlib.util.find_spec("strands.experimental.bidi") + assert spec is not None and spec.origin, "strands bidi package not found" + provider = pathlib.Path(spec.origin).parent / "models" / "bedrock.py" + assert provider.is_file(), ( + f"{provider} is missing — the bidi provider module was renamed again; " + "update the import in agents/main_agent/voice_agent.py" + ) + return provider.read_text() + + def test_provider_module_defines_the_class_we_import(self): + assert "class BedrockNovaSonicModel" in self._provider_source() + + def test_voice_agent_imports_the_current_provider_name(self): + """Read the module's import statements, not its prose. + + The comment above the import names the old symbol on purpose, so match + against the parsed AST rather than the raw text. + """ + import ast + import inspect + + import agents.main_agent.voice_agent as va + + imported = { + f"{node.module}.{alias.name}" + for node in ast.walk(ast.parse(inspect.getsource(va))) + if isinstance(node, ast.ImportFrom) and node.module + for alias in node.names + } + assert ( + "strands.experimental.bidi.models.bedrock.BedrockNovaSonicModel" in imported + ) + assert not any("BidiNovaSonicModel" in name for name in imported), ( + f"stale 1.51 provider name still imported: {sorted(imported)}" + ) + + def test_provider_takes_flattened_audio_and_region_kwargs(self): + """1.55.0 replaced provider_config/client_config with audio/region.""" + source = self._provider_source() + assert "audio: AudioConfig | None = None" in source + assert "region: str | None = None" in source + assert "provider_config" not in source + + def test_audio_config_still_carries_the_five_keys_we_send(self): + from strands.experimental.bidi.types.model import AudioConfig + + assert {"voice", "input_rate", "output_rate", "channels", "format"} <= set( + AudioConfig.__annotations__ + ) + + def test_nova_sonic_usage_is_still_cumulative(self): + """VoiceAgent de-cumulates bidi_usage; a switch to deltas would double-count.""" + assert "usage_is_cumulative = True" in self._provider_source() + + class TestVoiceConstants: """Req VA-2: Voice configuration constants.""" diff --git a/backend/tests/apis/app_api/test_mcp_apps_proxy_call.py b/backend/tests/apis/app_api/test_mcp_apps_proxy_call.py index ea514e03d..f82cec13b 100644 --- a/backend/tests/apis/app_api/test_mcp_apps_proxy_call.py +++ b/backend/tests/apis/app_api/test_mcp_apps_proxy_call.py @@ -132,3 +132,118 @@ def handler(_request: httpx.Request) -> httpx.Response: resp = TestClient(app).post("/mcp-apps/proxy-call", json=_BODY) assert resp.status_code == 502 + + +# --- AgentCore envelope translation ---------------------------------------- +# +# inference-api can't use HTTP status to report an app-tool error: AgentCore +# Runtime rewrites any non-2xx to a generic 424 and drops the message. It +# answers 200 + `appToolError` instead, and app-api restores the real status +# here. See `apis/shared/mcp_apps/error_envelope.py`. + + +def test_restores_status_and_message_from_envelope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The consent case that reached users as "check your CloudWatch logs".""" + consent = ( + "Authorization required for 'google_tasks'. Connect the account, " + "then try again." + ) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, json={"appToolError": {"code": 409, "message": consent}} + ) + + _patch_upstream(monkeypatch, handler) + app = _build_app(user_override=_user()) + + resp = TestClient(app).post("/mcp-apps/proxy-call", json=_BODY) + assert resp.status_code == 409 + # `error` feeds the App bridge; `detail` is what the SPA's global + # ErrorService renders in the toast. Without `detail` the user gets + # the generic "The request conflicts with the current state." + assert resp.json()["error"] == consent + assert resp.json()["detail"] == consent + + +def test_enveloped_error_never_relays_a_401( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 401 would trip the SPA interceptor and sign the user out.""" + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, json={"appToolError": {"code": 401, "message": "nope"}} + ) + + _patch_upstream(monkeypatch, handler) + app = _build_app(user_override=_user()) + + resp = TestClient(app).post("/mcp-apps/proxy-call", json=_BODY) + assert resp.status_code == 502 + + +def test_enveloped_error_persists_no_provenance_card( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An enveloped error is a failed call — it must not look like a success. + + The card write is gated on the upstream's 200, and an envelope now + arrives *with* a 200, so this is the regression the ordering guards + against. + """ + stored: list[dict] = [] + + class _Store: + def store(self, **kwargs: object) -> None: + stored.append(dict(kwargs)) + + monkeypatch.setattr( + "apis.app_api.mcp_apps.routes.get_app_card_store", lambda: _Store() + ) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, json={"appToolError": {"code": 409, "message": "connect"}} + ) + + _patch_upstream(monkeypatch, handler) + app = _build_app(user_override=_user()) + + resp = TestClient(app).post("/mcp-apps/proxy-call", json=_BODY) + assert resp.status_code == 409 + assert stored == [] + + +def test_successful_call_still_persists_a_card( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Control for the test above: the envelope check must not swallow success.""" + stored: list[dict] = [] + + class _Store: + def store(self, **kwargs: object) -> None: + stored.append(dict(kwargs)) + + monkeypatch.setattr( + "apis.app_api.mcp_apps.routes.get_app_card_store", lambda: _Store() + ) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "toolUseId": "tu-1", + "result": {"content": [{"text": "ok"}], "isError": False}, + }, + ) + + _patch_upstream(monkeypatch, handler) + app = _build_app(user_override=_user()) + + resp = TestClient(app).post("/mcp-apps/proxy-call", json=_BODY) + assert resp.status_code == 200 + assert len(stored) == 1 + assert stored[0]["tool_name"] == "widget_tool" diff --git a/backend/tests/apis/app_api/test_mcp_apps_update_context.py b/backend/tests/apis/app_api/test_mcp_apps_update_context.py index a0b428615..9c8d5d5b8 100644 --- a/backend/tests/apis/app_api/test_mcp_apps_update_context.py +++ b/backend/tests/apis/app_api/test_mcp_apps_update_context.py @@ -123,3 +123,41 @@ def handler(_request: httpx.Request) -> httpx.Response: resp = TestClient(app).post("/mcp-apps/update-context", json=_BODY) assert resp.status_code == 502 + + +def test_restores_status_and_message_from_envelope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Same AgentCore 424 flattening as proxy-call. + + See `apis/shared/mcp_apps/error_envelope.py`. + """ + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"appToolError": {"code": 400, "message": "needs content"}}, + ) + + _patch_upstream(monkeypatch, handler) + app = _build_app(user_override=_user()) + + resp = TestClient(app).post("/mcp-apps/update-context", json=_BODY) + assert resp.status_code == 400 + assert resp.json()["error"] == "needs content" + assert resp.json()["detail"] == "needs content" + + +def test_enveloped_error_never_relays_a_401( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, json={"appToolError": {"code": 401, "message": "nope"}} + ) + + _patch_upstream(monkeypatch, handler) + app = _build_app(user_override=_user()) + + resp = TestClient(app).post("/mcp-apps/update-context", json=_BODY) + assert resp.status_code == 502 diff --git a/backend/tests/apis/inference_api/test_app_tool_dispatch.py b/backend/tests/apis/inference_api/test_app_tool_dispatch.py index 7d4dbeda7..d460e2171 100644 --- a/backend/tests/apis/inference_api/test_app_tool_dispatch.py +++ b/backend/tests/apis/inference_api/test_app_tool_dispatch.py @@ -8,6 +8,7 @@ import pytest +from apis.inference_api.chat import app_tool_dispatch as dispatch_mod from apis.inference_api.chat.app_tool_dispatch import ( AppToolCallError, dispatch_app_tool_call, @@ -261,3 +262,336 @@ async def test_leaves_a_live_client_session_alone(monkeypatch): assert client.starts == 0 assert client.stops == 0 assert client.active is True + + +class _FakeIntegration: + """Stands in for the process-wide `ExternalMCPIntegration` singleton.""" + + def __init__(self, provider_id=None) -> None: + self._provider_id = provider_id + + def provider_for_client(self, _client): + return self._provider_id + + +class _FakeDisconnectRepo: + def __init__(self, disconnected: bool = False) -> None: + self._disconnected = disconnected + self.calls: list = [] + + async def is_disconnected(self, user_id, provider_id) -> bool: + self.calls.append((user_id, provider_id)) + return self._disconnected + + +def _patch_oauth( + monkeypatch, + *, + provider_id="google-tasks", + resolved=None, + disconnected=False, +): + """Wire the OAuth collaborators `_ensure_oauth_token` reaches for. + + Each is imported lazily inside the dispatch module, so patching the + owning module's attribute is what the call actually resolves. + """ + from agents.main_agent.integrations import external_mcp_client + from apis.shared.oauth import disconnect_repository, token_resolution + + monkeypatch.setattr( + external_mcp_client, + "get_external_mcp_integration", + lambda: _FakeIntegration(provider_id), + ) + repo = _FakeDisconnectRepo(disconnected) + monkeypatch.setattr( + disconnect_repository, "get_disconnect_repository", lambda: repo + ) + + calls: list = [] + + async def _resolve(pid, uid, *, force_authentication=False): + calls.append((pid, uid, force_authentication)) + return resolved + + monkeypatch.setattr(token_resolution, "resolve_token_or_consent_url", _resolve) + return calls + + +@pytest.fixture +def token_cache(): + """The OAuth token cache is process-global — isolate each test.""" + from agents.main_agent.integrations import oauth_token_cache + + oauth_token_cache.clear_user("u1") + yield oauth_token_cache + oauth_token_cache.clear_user("u1") + + +@pytest.mark.asyncio +async def test_warms_a_cold_token_cache_from_the_vault(monkeypatch, token_cache): + """The reported bug: an App's tool calls fail after a page reload. + + `OAuthConsentHook` warms the token cache on `BeforeToolCallEvent` — an + event this path never raises. On any container that has not run a + model-driven turn for this (user, provider) the cache is cold, the lazy + token provider returns None, and the call goes out with no Authorization + header. A server that allows an unauthenticated `tools/list` still lists + the tool, so the App renders and then every button answers with the + server's own "you aren't connected" text. + """ + client = _FakeClient() + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + calls = _patch_oauth(monkeypatch, resolved={"token": "tok-1", "url": None}) + + payload = await _call(session_id="disp-oauth-cold") + + assert payload["result"]["isError"] is False + # Resolved before the tool ran, so the request carries a Bearer token. + assert calls == [("google-tasks", "u1", False)] + assert token_cache.get("u1", "google-tasks") == "tok-1" + + +@pytest.mark.asyncio +async def test_warm_cache_skips_the_vault(monkeypatch, token_cache): + client = _FakeClient() + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + calls = _patch_oauth(monkeypatch, resolved={"token": "fresh", "url": None}) + token_cache.set("u1", "google-tasks", "already-warm") + + await _call(session_id="disp-oauth-warm") + + assert calls == [] + assert token_cache.get("u1", "google-tasks") == "already-warm" + + +@pytest.mark.asyncio +async def test_consent_required_surfaces_as_409(monkeypatch, token_cache): + """No vaulted token means the user really hasn't connected the account. + + 409, not 401: the SPA's error interceptor reads any 401 as an expired + BFF session and redirects to login, so a 401 here would sign the user + out over an unconnected connector. + """ + client = _FakeClient() + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + _patch_oauth( + monkeypatch, resolved={"token": None, "url": "https://consent.example"} + ) + + with pytest.raises(AppToolCallError) as ei: + await _call(session_id="disp-oauth-consent") + + assert ei.value.code == 409 + assert "google-tasks" in ei.value.message + # Never dispatched — there was no token to dispatch with. + assert client.calls == [] + + +@pytest.mark.asyncio +async def test_unresolvable_provider_still_dispatches(monkeypatch, token_cache): + """`None` means "couldn't ask AgentCore", not "user must consent". + + Prompting on it would tell a connected user to connect. Let the call go + out instead — the server's own error is a truer report than a guess. + """ + client = _FakeClient() + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + _patch_oauth(monkeypatch, resolved=None) + + payload = await _call(session_id="disp-oauth-unresolved") + + assert payload["result"]["isError"] is False + assert len(client.calls) == 1 + + +@pytest.mark.asyncio +async def test_disconnected_user_bypasses_the_cached_token(monkeypatch, token_cache): + """A disconnect must reach an App frame that is still open on screen. + + Without this the App keeps working off the cached token for the rest of + its TTL after the user presses Disconnect. + """ + client = _FakeClient() + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + _patch_oauth( + monkeypatch, + resolved={"token": None, "url": "https://consent.example"}, + disconnected=True, + ) + token_cache.set("u1", "google-tasks", "stale-post-disconnect") + + with pytest.raises(AppToolCallError) as ei: + await _call(session_id="disp-oauth-disconnected") + + assert ei.value.code == 409 + assert token_cache.get("u1", "google-tasks") is None + + +@pytest.mark.asyncio +async def test_forces_reauth_when_disconnected(monkeypatch, token_cache): + client = _FakeClient() + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + calls = _patch_oauth( + monkeypatch, + resolved={"token": "re-consented", "url": None}, + disconnected=True, + ) + + await _call(session_id="disp-oauth-force") + + assert calls == [("google-tasks", "u1", True)] + + +@pytest.mark.asyncio +async def test_auth_shaped_failure_clears_the_cached_token(monkeypatch, token_cache): + """A rejected token must not stay cached for the rest of its TTL. + + Cleared, not retried: an app-initiated call is whatever button the user + pressed, so re-firing a mutation off a regex match could apply the side + effect twice. + """ + client = _FakeClient(_FakeResult("401 Unauthorized", is_error=True)) + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + _patch_oauth(monkeypatch, resolved={"token": "revoked", "url": None}) + + payload = await _call(session_id="disp-oauth-401") + + assert payload["result"]["isError"] is True + assert token_cache.get("u1", "google-tasks") is None + # One call only — no automatic retry of a possibly-mutating tool. + assert len(client.calls) == 1 + + +@pytest.mark.asyncio +async def test_ordinary_tool_error_keeps_the_cached_token(monkeypatch, token_cache): + """Only auth-shaped failures invalidate the token.""" + client = _FakeClient(_FakeResult("Task not found", is_error=True)) + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + _patch_oauth(monkeypatch, resolved={"token": "tok-1", "url": None}) + + await _call(session_id="disp-oauth-plain-error") + + assert token_cache.get("u1", "google-tasks") == "tok-1" + + +@pytest.mark.asyncio +async def test_non_oauth_client_never_asks_agentcore(monkeypatch, token_cache): + """A SigV4 / unauthenticated MCP server has no provider to resolve.""" + client = _FakeClient() + _patch(monkeypatch, enabled=True, meta=_ui(["model", "app"]), client=client) + calls = _patch_oauth(monkeypatch, provider_id=None, resolved=None) + + payload = await _call(session_id="disp-oauth-none") + + assert payload["result"]["isError"] is False + assert calls == [] + +# --- opportunistic UI-resource revalidation --------------------------------- + + +class _FakeResourceStore: + """Stand-in for the `UIRES#` store: one provenance row, capture writes.""" + + def __init__(self, provenance=None): + self._provenance = provenance + self.stored: list = [] + + def get_provenance(self, *, user_id, tool_use_id): + return self._provenance + + def store(self, **kwargs): + self.stored.append(kwargs) + + +@pytest.fixture(autouse=True) +def _clear_refresh_claims(): + """The once-per-process claim set is module state — reset per test.""" + dispatch_mod._refreshed_resources.clear() + yield + dispatch_mod._refreshed_resources.clear() + + +def test_claim_refresh_is_once_per_resource(): + assert dispatch_mod._claim_refresh("s1", "tu1") is True + # A chatty App presses buttons all day; the shell is read exactly once. + assert dispatch_mod._claim_refresh("s1", "tu1") is False + # A different frame in the same conversation is its own resource. + assert dispatch_mod._claim_refresh("s1", "tu2") is True + + +def test_claim_refresh_bounds_its_memory(): + for i in range(dispatch_mod._MAX_REFRESHED): + dispatch_mod._claim_refresh("s", f"tu{i}") + assert len(dispatch_mod._refreshed_resources) == dispatch_mod._MAX_REFRESHED + dispatch_mod._claim_refresh("s", "overflow") + assert len(dispatch_mod._refreshed_resources) == 1 + + +def test_refresh_rereads_the_producing_tool_and_preserves_the_anchor(monkeypatch): + store = _FakeResourceStore( + {"toolName": "view_task_board", "producedByMessageIndex": 4} + ) + monkeypatch.setattr( + "apis.shared.mcp_apps.ui_resource_store.get_ui_resource_store", + lambda: store, + ) + monkeypatch.setattr( + dispatch_mod, "_resolve_client", lambda agent, name: (object(), _FakeClient()) + ) + seen = {} + + def _fetch(tool_name, tool_use_id): + seen["tool_name"] = tool_name + return { + "resourceUri": "ui://tasks/board", + "html": "fresh", + "mimeType": "text/html", + "csp": {"connect-src": ["'self'"]}, + "permissions": {}, + "sandboxOrigin": "https://sandbox.example", + "serverName": "Google Tasks", + "icon": "", + } + + monkeypatch.setattr(mcp_apps_mod, "fetch_ui_resource", _fetch) + + dispatch_mod._refresh_ui_resource("u1", "s1", "tu1") + + # Re-read is keyed on the tool that PRODUCED the frame, not whatever the + # App just called — the resourceUri hangs off the producing tool. + assert seen["tool_name"] == "view_task_board" + assert len(store.stored) == 1 + written = store.stored[0] + assert written["html"] == "fresh" + assert written["csp"] == {"connect-src": ["'self'"]} + assert written["tool_name"] == "view_task_board" + # The anchor is the producing turn's; a refresh must not renumber it. + assert written["produced_by_message_index"] == 4 + + +def test_refresh_no_ops_without_a_stored_row(monkeypatch): + store = _FakeResourceStore(None) + monkeypatch.setattr( + "apis.shared.mcp_apps.ui_resource_store.get_ui_resource_store", + lambda: store, + ) + dispatch_mod._refresh_ui_resource("u1", "s1", "tu1") + assert store.stored == [] + + +def test_refresh_keeps_the_old_copy_when_the_read_returns_nothing(monkeypatch): + """A server that is down must not blank an App that still works.""" + store = _FakeResourceStore({"toolName": "view_task_board"}) + monkeypatch.setattr( + "apis.shared.mcp_apps.ui_resource_store.get_ui_resource_store", + lambda: store, + ) + monkeypatch.setattr( + dispatch_mod, "_resolve_client", lambda agent, name: (object(), _FakeClient()) + ) + monkeypatch.setattr(mcp_apps_mod, "fetch_ui_resource", lambda *a: None) + + dispatch_mod._refresh_ui_resource("u1", "s1", "tu1") + assert store.stored == [] diff --git a/backend/tests/fine_tuning/test_inference_script.py b/backend/tests/fine_tuning/test_inference_script.py index d82c07e42..d97af78be 100644 --- a/backend/tests/fine_tuning/test_inference_script.py +++ b/backend/tests/fine_tuning/test_inference_script.py @@ -344,3 +344,84 @@ def test_uses_class_prefix_when_no_id2label(self): ) assert result["labels"] == ["class_0", "class_1", "class_2"] + + +class TestGenerativeOutputFn: + """A generative task emits text, not a probability per class. + + The result is still one CSV row per input record, so the download and the + result viewer are unchanged by the new modality. + """ + + def _prediction(self, **overrides): + prediction = { + "identifiers": ["cat.jpg"], + "prompts": ["What is this?"], + "generations": ["A tabby cat."], + "identifier_column": "image", + } + prediction.update(overrides) + return prediction + + def test_header_names_prompt_and_output(self): + header = output_fn(self._prediction()).split("\n")[0] + assert header == "image,prompt,output" + + def test_row_carries_the_generated_text(self): + rows = output_fn(self._prediction()).split("\n") + assert rows[1] == '"cat.jpg","What is this?","A tabby cat."' + + def test_generative_shape_is_chosen_without_a_flag(self): + """Dispatch is on what the task produced, not on a caller-passed hint.""" + classification = { + "identifiers": ["a"], + "probabilities": np.array([[0.25, 0.75]]), + "labels": ["no", "yes"], + "identifier_column": "id", + } + assert "prob_yes" in output_fn(classification) + assert "prob_" not in output_fn(self._prediction()) + + def test_embedded_newlines_are_quoted(self): + """Generated text is multi-line far more often than a label is.""" + rendered = output_fn( + self._prediction(generations=["line one\nline two"]) + ) + assert '"line one\nline two"' in rendered + + def test_embedded_quotes_are_doubled(self): + rendered = output_fn(self._prediction(generations=['He said "hi"'])) + assert '"He said ""hi"""' in rendered + + def test_embedded_commas_do_not_split_the_row(self): + rendered = output_fn(self._prediction(generations=["red, green, blue"])) + assert '"red, green, blue"' in rendered + + def test_multiple_records(self): + rendered = output_fn( + self._prediction( + identifiers=["a.jpg", "b.jpg"], + prompts=["p1", "p2"], + generations=["g1", "g2"], + ) + ) + assert len(rendered.split("\n")) == 3 + + def test_missing_prompts_do_not_drop_the_row(self): + """A short prompts list must degrade to blank, not truncate results.""" + rendered = output_fn( + self._prediction( + identifiers=["a.jpg", "b.jpg"], + prompts=[], + generations=["g1", "g2"], + ) + ) + rows = rendered.split("\n") + assert len(rows) == 3 + assert rows[2] == '"b.jpg","","g2"' + + def test_empty_prediction_still_emits_a_header(self): + rendered = output_fn( + self._prediction(identifiers=[], prompts=[], generations=[]) + ) + assert rendered == "image,prompt,output" diff --git a/backend/tests/fine_tuning/test_script_packaging_service.py b/backend/tests/fine_tuning/test_script_packaging_service.py index e00c167fb..963b99938 100644 --- a/backend/tests/fine_tuning/test_script_packaging_service.py +++ b/backend/tests/fine_tuning/test_script_packaging_service.py @@ -1,14 +1,24 @@ """Unit tests for ScriptPackagingService.""" +import io +import tarfile + import pytest -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock from botocore.exceptions import ClientError +from apis.app_api.fine_tuning import task_types from apis.app_api.fine_tuning.script_packaging_service import ( ScriptPackagingService, - SCRIPTS_S3_KEY, + scripts_s3_key, ) +TEXT = task_types.TEXT_CLASSIFICATION +VLM = task_types.IMAGE_TEXT_TO_TEXT + +TEXT_KEY = scripts_s3_key(task_types.DLC_FAMILY_TEXT) +VLM_KEY = scripts_s3_key(task_types.DLC_FAMILY_VLM) + @pytest.fixture def mock_s3(): @@ -20,6 +30,12 @@ def service(mock_s3): return ScriptPackagingService(s3_client=mock_s3, bucket_name="test-bucket") +def _archive_member(tar_bytes, name): + """Return one member's bytes from an in-memory tar.gz.""" + with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:gz") as tar: + return tar.extractfile(name).read().decode("utf-8") + + class TestEnsureScriptsUploaded: def test_uploads_when_not_in_s3(self, service, mock_s3): @@ -29,25 +45,25 @@ def test_uploads_when_not_in_s3(self, service, mock_s3): "HeadObject", ) - result = service.ensure_scripts_uploaded() + result = service.ensure_scripts_uploaded(TEXT) - assert result == f"s3://test-bucket/{SCRIPTS_S3_KEY}" + assert result == f"s3://test-bucket/{TEXT_KEY}" mock_s3.put_object.assert_called_once() call_kwargs = mock_s3.put_object.call_args[1] assert call_kwargs["Bucket"] == "test-bucket" - assert call_kwargs["Key"] == SCRIPTS_S3_KEY + assert call_kwargs["Key"] == TEXT_KEY assert "content-hash" in call_kwargs["Metadata"] def test_skips_upload_when_hash_matches(self, service, mock_s3): """Should skip upload when S3 object has matching content hash.""" - content_hash = service._compute_content_hash() + content_hash = service._compute_content_hash(task_types.DLC_FAMILY_TEXT) mock_s3.head_object.return_value = { "Metadata": {"content-hash": content_hash}, } - result = service.ensure_scripts_uploaded() + result = service.ensure_scripts_uploaded(TEXT) - assert result == f"s3://test-bucket/{SCRIPTS_S3_KEY}" + assert result == f"s3://test-bucket/{TEXT_KEY}" mock_s3.put_object.assert_not_called() def test_reuploads_when_hash_differs(self, service, mock_s3): @@ -56,25 +72,32 @@ def test_reuploads_when_hash_differs(self, service, mock_s3): "Metadata": {"content-hash": "stale-hash-from-old-scripts"}, } - result = service.ensure_scripts_uploaded() + result = service.ensure_scripts_uploaded(TEXT) - assert result == f"s3://test-bucket/{SCRIPTS_S3_KEY}" + assert result == f"s3://test-bucket/{TEXT_KEY}" mock_s3.put_object.assert_called_once() def test_caches_uri_after_first_call(self, service, mock_s3): """Should cache URI and skip S3 checks on subsequent calls.""" - content_hash = service._compute_content_hash() + content_hash = service._compute_content_hash(task_types.DLC_FAMILY_TEXT) mock_s3.head_object.return_value = { "Metadata": {"content-hash": content_hash}, } - uri1 = service.ensure_scripts_uploaded() - uri2 = service.ensure_scripts_uploaded() + uri1 = service.ensure_scripts_uploaded(TEXT) + uri2 = service.ensure_scripts_uploaded(TEXT) assert uri1 == uri2 # head_object should only be called once (cached after first call) mock_s3.head_object.assert_called_once() + def test_omitting_the_task_uses_the_legacy_default(self, service, mock_s3): + """A caller that passes nothing gets the text family, as before.""" + mock_s3.head_object.side_effect = ClientError( + {"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject" + ) + assert service.ensure_scripts_uploaded() == f"s3://test-bucket/{TEXT_KEY}" + def test_sets_metadata_on_upload(self, service, mock_s3): """Should include content-hash metadata when uploading.""" mock_s3.head_object.side_effect = ClientError( @@ -82,7 +105,7 @@ def test_sets_metadata_on_upload(self, service, mock_s3): "HeadObject", ) - service.ensure_scripts_uploaded() + service.ensure_scripts_uploaded(TEXT) call_kwargs = mock_s3.put_object.call_args[1] metadata = call_kwargs["Metadata"] @@ -90,17 +113,83 @@ def test_sets_metadata_on_upload(self, service, mock_s3): assert len(metadata["content-hash"]) == 64 # SHA256 hex digest +class TestPerFamilyPackaging: + """One archive per DLC family, because the dependency sets differ.""" + + def test_families_use_separate_keys(self, service, mock_s3): + """A shared key would let the last job overwrite another family's deps.""" + mock_s3.head_object.side_effect = ClientError( + {"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject" + ) + + assert service.ensure_scripts_uploaded(TEXT) == f"s3://test-bucket/{TEXT_KEY}" + assert service.ensure_scripts_uploaded(VLM) == f"s3://test-bucket/{VLM_KEY}" + assert TEXT_KEY != VLM_KEY + + def test_cache_is_per_family(self, service, mock_s3): + """A cached text URI must not be served to a VLM job.""" + mock_s3.head_object.side_effect = ClientError( + {"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject" + ) + + service.ensure_scripts_uploaded(TEXT) + service.ensure_scripts_uploaded(VLM) + + keys = [c[1]["Key"] for c in mock_s3.put_object.call_args_list] + assert keys == [TEXT_KEY, VLM_KEY] + + def test_family_hashes_differ(self, service): + """Same scripts, different requirements — the hash has to notice.""" + assert service._compute_content_hash( + task_types.DLC_FAMILY_TEXT + ) != service._compute_content_hash(task_types.DLC_FAMILY_VLM) + + def test_vlm_archive_carries_the_peft_stack(self, service): + """peft and bitsandbytes must reach the container that needs them.""" + tar_bytes = service._create_tar_gz(task_types.DLC_FAMILY_VLM) + requirements = _archive_member(tar_bytes, "requirements.txt") + assert "peft==" in requirements + assert "bitsandbytes==" in requirements + + def test_text_archive_excludes_bitsandbytes(self, service): + """bitsandbytes needs torch>=2.4; the text DLC is torch 2.1. + + Shipping it to the text container would break dependency installation + for every existing text job. + """ + tar_bytes = service._create_tar_gz(task_types.DLC_FAMILY_TEXT) + requirements = _archive_member(tar_bytes, "requirements.txt") + assert "bitsandbytes" not in requirements + assert "peft" not in requirements + + def test_every_family_packages_a_requirements_file(self, service): + """A family with no requirements.txt installs nothing and fails late.""" + for family in ( + task_types.DLC_FAMILY_TEXT, + task_types.DLC_FAMILY_VISION, + task_types.DLC_FAMILY_VLM, + ): + names = [n for n, _ in service._source_paths(family)] + assert names.count("requirements.txt") == 1 + + def test_every_task_module_is_packaged(self, service): + """train.py imports every task module at load time.""" + names = [n for n, _ in service._source_paths(task_types.DLC_FAMILY_VLM)] + assert "task_image_text_to_text.py" in names + assert "task_types.py" in names + + class TestComputeContentHash: def test_returns_consistent_hash(self, service): """Same scripts should produce the same hash.""" - hash1 = service._compute_content_hash() - hash2 = service._compute_content_hash() + hash1 = service._compute_content_hash(task_types.DLC_FAMILY_TEXT) + hash2 = service._compute_content_hash(task_types.DLC_FAMILY_TEXT) assert hash1 == hash2 def test_hash_is_64_char_hex(self, service): """SHA256 hex digest should be 64 characters.""" - content_hash = service._compute_content_hash() + content_hash = service._compute_content_hash(task_types.DLC_FAMILY_TEXT) assert len(content_hash) == 64 assert all(c in "0123456789abcdef" for c in content_hash) @@ -109,11 +198,11 @@ class TestCreateTarGz: def test_produces_non_empty_bytes(self, service): """Should create a non-empty tar.gz archive.""" - tar_bytes = service._create_tar_gz() + tar_bytes = service._create_tar_gz(task_types.DLC_FAMILY_TEXT) assert len(tar_bytes) > 0 def test_is_valid_gzip(self, service): """Output should start with gzip magic bytes.""" - tar_bytes = service._create_tar_gz() + tar_bytes = service._create_tar_gz(task_types.DLC_FAMILY_TEXT) # Gzip magic bytes: 0x1f 0x8b assert tar_bytes[0:2] == b"\x1f\x8b" diff --git a/backend/tests/fine_tuning/test_task_types.py b/backend/tests/fine_tuning/test_task_types.py index 4c2b0d51a..09d77cc8c 100644 --- a/backend/tests/fine_tuning/test_task_types.py +++ b/backend/tests/fine_tuning/test_task_types.py @@ -37,9 +37,19 @@ def test_task_order_is_deterministic(self): assert list(task_types.TASK_SPECS) == list(task_types.TASK_SPECS) @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) - def test_label_column_is_required(self, task_type): + def test_the_target_column_is_required(self, task_type): + """Whatever a task learns to predict has to be in every record. + + For a classifier that is the label column; for a generative task + there is no label set at all and the target is the response text. + """ spec = task_types.get_task_spec(task_type) - assert spec.label_column in spec.required_columns + if spec.is_generative: + assert spec.label_column is None + assert spec.response_column in spec.required_columns + else: + assert spec.response_column is None + assert spec.label_column in spec.required_columns @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) def test_image_tasks_require_an_archive(self, task_type): @@ -61,8 +71,21 @@ def test_archive_task_list_matches_the_specs(self): assert task_types.ARCHIVE_TASK_TYPES == ( task_types.IMAGE_CLASSIFICATION, task_types.IMAGE_TEXT_CLASSIFICATION, + task_types.IMAGE_TEXT_TO_TEXT, ) + def test_generative_task_list_matches_the_specs(self): + assert task_types.GENERATIVE_TASK_TYPES == (task_types.IMAGE_TEXT_TO_TEXT,) + + def test_is_generative_predicate(self): + assert not task_types.is_generative(task_types.TEXT_CLASSIFICATION) + assert not task_types.is_generative(task_types.IMAGE_TEXT_CLASSIFICATION) + assert task_types.is_generative(task_types.IMAGE_TEXT_TO_TEXT) + + def test_legacy_rows_are_never_generative(self): + """A row with no task_type predates task types and is a classifier.""" + assert not task_types.is_generative(None) + @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) def test_payload_size_is_within_batch_transform_limits(self, task_type): """Batch Transform caps MaxPayloadInMB at 100.""" @@ -77,6 +100,59 @@ def test_default_instance_is_priced(self, task_type): assert pricing.transform_rate(spec.default_instance_type) is not None +class TestGenerativeTask: + """Invariants specific to image-text-to-text.""" + + def _spec(self): + return task_types.get_task_spec(task_types.IMAGE_TEXT_TO_TEXT) + + def test_pipeline_tag_matches_the_hub(self): + """The pre-flight compares this against the Hub's own tag verbatim. + + llava-hf/llava-v1.6-34b-hf and every other catalog VLM is tagged + "image-text-to-text"; a different spelling here rejects all of them. + """ + assert self._spec().hf_pipeline_tags == ("image-text-to-text",) + + def test_generative_tag_stays_out_of_the_dual_encoder_task(self): + """image-text-classification needs a text tower to pool. + + Letting a generative tag through there passes the pre-flight and then + fails on a billed GPU inside the dual-encoder check. + """ + fusion = task_types.get_task_spec(task_types.IMAGE_TEXT_CLASSIFICATION) + assert task_types.IMAGE_TEXT_TO_TEXT not in fusion.hf_pipeline_tags + + def test_prompt_and_response_are_distinct_columns(self): + spec = self._spec() + assert spec.text_column == "prompt" + assert spec.response_column == "response" + assert spec.text_column != spec.response_column + + def test_runs_in_its_own_dlc_family(self): + """Sharing the vision family would put bitsandbytes in its requirements.""" + spec = self._spec() + assert spec.dlc_family == task_types.DLC_FAMILY_VLM + assert spec.dlc_family != task_types.DLC_FAMILY_VISION + + def test_default_instance_has_enough_vram_for_a_quantised_vlm(self): + """24GB cards OOM on the larger catalog entries.""" + spec = self._spec() + assert pricing.ACCELERATOR_MEMORY_GB[spec.default_instance_type] >= 48 + + def test_lora_defaults_are_present(self): + """The trainer reads these straight off the hyperparameters.""" + defaults = self._spec().default_hyperparameters + for key in ("lora_r", "lora_alpha", "lora_dropout", "load_in_4bit"): + assert key in defaults + + def test_effective_batch_is_recovered_by_accumulation(self): + """A literal batch of 1 would make the gradient estimate very noisy.""" + defaults = self._spec().default_hyperparameters + assert int(defaults["per_device_train_batch_size"]) == 1 + assert int(defaults["gradient_accumulation_steps"]) > 1 + + class TestCatalog: @pytest.mark.parametrize("model", AVAILABLE_MODELS, ids=lambda m: m.model_id) diff --git a/backend/tests/fine_tuning/test_vlm_task.py b/backend/tests/fine_tuning/test_vlm_task.py new file mode 100644 index 000000000..a386f94c5 --- /dev/null +++ b/backend/tests/fine_tuning/test_vlm_task.py @@ -0,0 +1,285 @@ +"""Unit tests for the generative vision-language task module. + +``task_image_text_to_text`` runs inside the SageMaker DLC, where torch, +transformers and peft exist. They do not exist in the backend venv, so these +cover the parts that must hold *before* a GPU is billed: the module imports +cleanly, the chat rendering is faithful to the checkpoint's own template, the +label-masking arithmetic cannot produce an all-masked row, and the adapter +sidecar records what inference needs to rebuild the pair. +""" + +import json + +import pytest +from unittest.mock import MagicMock + +from apis.app_api.fine_tuning import task_types +from apis.app_api.fine_tuning.sagemaker_scripts import task_image_text_to_text as vlm +from apis.app_api.fine_tuning.sagemaker_scripts import train as train_script + +SPEC = task_types.get_task_spec(task_types.IMAGE_TEXT_TO_TEXT) + + +def _processor(chat_template="{{ messages }}"): + """A processor stub exposing only what render_chat touches.""" + processor = MagicMock() + processor.chat_template = chat_template + processor.apply_chat_template.side_effect = ( + lambda messages, tokenize, add_generation_prompt: json.dumps( + {"messages": messages, "generation_prompt": add_generation_prompt} + ) + ) + return processor + + +class TestImportContract: + """The module must load without the ML stack, like its siblings.""" + + def test_imports_without_torch(self): + import sys + + assert "torch" not in sys.modules + assert vlm.LABEL_IGNORE_INDEX == -100 + + def test_registered_in_both_dispatchers(self): + from apis.app_api.fine_tuning.sagemaker_scripts import inference + + assert train_script.TASK_MODULES[task_types.IMAGE_TEXT_TO_TEXT] is vlm + assert inference.TASK_MODULES[task_types.IMAGE_TEXT_TO_TEXT] is vlm + + +class TestBuildMessages: + + def test_prompt_only_has_no_assistant_turn(self): + messages = vlm.build_messages("What is this?") + assert len(messages) == 1 + assert messages[0]["role"] == "user" + + def test_image_precedes_text_in_the_user_turn(self): + """Every catalog template expects the image first.""" + content = vlm.build_messages("caption it")[0]["content"] + assert [part["type"] for part in content] == ["image", "text"] + + def test_response_becomes_the_assistant_turn(self): + messages = vlm.build_messages("q", "a") + assert messages[1]["role"] == "assistant" + assert messages[1]["content"][0]["text"] == "a" + + def test_non_string_fields_are_coerced(self): + """A CSV column of digits loads as int64 and would break the template.""" + messages = vlm.build_messages(42, 7) + assert messages[0]["content"][1]["text"] == "42" + assert messages[1]["content"][0]["text"] == "7" + + def test_empty_response_still_produces_a_turn(self): + """An empty string is a real target; only None means prompt-only.""" + assert len(vlm.build_messages("q", "")) == 2 + + +class TestRenderChat: + + def test_uses_the_processor_template(self): + processor = _processor() + rendered = vlm.render_chat( + processor, vlm.build_messages("q", "a"), add_generation_prompt=False + ) + assert json.loads(rendered)["generation_prompt"] is False + processor.apply_chat_template.assert_called_once() + + def test_generation_prompt_flag_is_passed_through(self): + rendered = vlm.render_chat( + _processor(), vlm.build_messages("q"), add_generation_prompt=True + ) + assert json.loads(rendered)["generation_prompt"] is True + + def test_template_on_the_tokenizer_is_accepted(self): + """Some processors carry the template on their tokenizer instead.""" + processor = _processor(chat_template=None) + processor.tokenizer.chat_template = "{{ messages }}" + assert vlm.render_chat(processor, vlm.build_messages("q"), False) + + def test_missing_template_raises_rather_than_inventing_a_format(self): + """Guessing trains the model on a dialogue shape it has never seen.""" + processor = _processor(chat_template=None) + processor.tokenizer.chat_template = None + with pytest.raises(ValueError, match="no chat template"): + vlm.render_chat(processor, vlm.build_messages("q"), False) + + +class TestPromptMaskLimit: + """Loss is computed on the response only — but never on nothing.""" + + def test_masks_the_whole_prompt_when_the_response_survives(self): + assert vlm.prompt_mask_limit(10, 40) == 10 + + def test_never_masks_the_final_position(self): + """An all-masked row yields NaN loss and poisons the batch average.""" + assert vlm.prompt_mask_limit(40, 40) == 39 + assert vlm.prompt_mask_limit(100, 40) == 39 + + def test_zero_length_prompt_masks_nothing(self): + assert vlm.prompt_mask_limit(0, 40) == 0 + + def test_never_returns_a_negative_limit(self): + """A degenerate single-token row must not produce a negative slice.""" + assert vlm.prompt_mask_limit(0, 1) == 0 + assert vlm.prompt_mask_limit(5, 1) == 0 + + +class TestResolveImageTokenIds: + + def test_reads_the_config_attribute(self): + config = MagicMock(spec=["image_token_index"]) + config.image_token_index = 32000 + assert 32000 in vlm.resolve_image_token_ids(MagicMock(), config) + + def test_reads_the_renamed_attribute(self): + """transformers renamed this between the versions we run.""" + config = MagicMock(spec=["image_token_id"]) + config.image_token_id = 151655 + assert 151655 in vlm.resolve_image_token_ids(MagicMock(), config) + + def test_resolves_through_the_tokenizer(self): + processor = MagicMock() + processor.image_token = "" + processor.tokenizer.convert_tokens_to_ids.return_value = 128256 + assert 128256 in vlm.resolve_image_token_ids(processor, MagicMock(spec=[])) + + def test_unknown_placeholder_is_not_an_error(self): + """Pad and prompt masking still apply; a few live placeholders only + nudge the loss, so an unrecognised architecture must not fail here.""" + processor = MagicMock() + processor.image_token = None + assert vlm.resolve_image_token_ids(processor, MagicMock(spec=[])) == set() + + def test_ignores_a_non_integer_attribute(self): + config = MagicMock(spec=["image_token_index"]) + config.image_token_index = "" + processor = MagicMock() + processor.image_token = None + assert vlm.resolve_image_token_ids(processor, config) == set() + + +class TestQuantizationConfig: + + def test_disabled_returns_none_without_importing_bitsandbytes(self): + """bf16 training must not require the quantisation stack at all.""" + assert vlm.build_quantization_config(False) is None + + +class TestAdapterConfig: + """Only the adapter is saved; this sidecar names the base it belongs to.""" + + def _args(self, **overrides): + defaults = { + "model_name_or_path": "llava-hf/llava-v1.6-34b-hf", + "load_in_4bit": True, + "max_new_tokens": 256, + } + defaults.update(overrides) + return MagicMock(**defaults) + + def test_records_the_base_model(self, tmp_path): + vlm.save_adapter_config(str(tmp_path), self._args()) + payload = json.loads( + (tmp_path / vlm.ADAPTER_CONFIG_FILENAME).read_text() + ) + assert payload["base_model_id"] == "llava-hf/llava-v1.6-34b-hf" + + def test_records_the_quantisation_mode(self, tmp_path): + """Loading a bf16-trained adapter over a 4-bit base changes the model.""" + vlm.save_adapter_config(str(tmp_path), self._args(load_in_4bit=False)) + payload = json.loads( + (tmp_path / vlm.ADAPTER_CONFIG_FILENAME).read_text() + ) + assert payload["load_in_4bit"] is False + + def test_creates_a_missing_directory(self, tmp_path): + target = tmp_path / "model" + vlm.save_adapter_config(str(target), self._args()) + assert (target / vlm.ADAPTER_CONFIG_FILENAME).is_file() + + +class TestBooleanHyperparameters: + """SageMaker passes every hyperparameter as a string.""" + + @pytest.mark.parametrize("value", ["true", "True", "1", "yes", "on", True]) + def test_truthy(self, value): + assert train_script.str2bool(value) is True + + @pytest.mark.parametrize("value", ["false", "False", "0", "no", "off", "", False]) + def test_falsy(self, value): + assert train_script.str2bool(value) is False + + def test_the_string_false_is_not_truthy(self): + """bool("false") is True — the whole reason this parser exists.""" + assert bool("false") is True + assert train_script.str2bool("false") is False + + def test_parser_applies_it_to_load_in_4bit(self): + args = train_script.parse_args( + ["--model_name_or_path", "x", "--load_in_4bit", "false"] + ) + assert args.load_in_4bit is False + + +class TestGenerativeHyperparameterPlumbing: + """Every default in the registry must reach a real parser argument.""" + + @pytest.mark.parametrize("key", sorted(SPEC.default_hyperparameters)) + def test_default_is_a_known_argument(self, key): + args = train_script.parse_args(["--model_name_or_path", "x"]) + # split_ratio/seed/epochs etc. are shared; the LoRA ones are new. + assert hasattr(args, key), f"{key} has no --{key} argument" + + def test_defaults_parse_as_their_declared_types(self): + argv = ["--model_name_or_path", "x", "--task_type", SPEC.task_type] + for key, value in SPEC.default_hyperparameters.items(): + argv += [f"--{key}", value] + args = train_script.parse_args(argv) + + assert args.lora_r == 16 + assert args.lora_alpha == 32 + assert args.lora_dropout == pytest.approx(0.05) + assert args.load_in_4bit is True + assert args.gradient_accumulation_steps == 8 + assert args.max_new_tokens == 256 + assert args.learning_rate == pytest.approx(1e-4) + + +class TestCollationCheck: + """A canary batch, so a bad pairing fails before the GPU bill starts.""" + + def test_passes_a_working_collator(self): + vlm.check_collation(lambda batch: {"ok": True}, [{"a": 1}, {"a": 2}]) + + def test_reraises_with_actionable_guidance(self): + def collator(_batch): + raise RuntimeError("Image features and image tokens do not match") + + with pytest.raises(ValueError, match="context_length"): + vlm.check_collation(collator, [{"a": 1}]) + + def test_preserves_the_original_error(self): + def collator(_batch): + raise RuntimeError("tokens do not match: 2928 vs 1024") + + with pytest.raises(ValueError, match="2928 vs 1024"): + vlm.check_collation(collator, [{"a": 1}]) + + def test_samples_at_most_the_requested_records(self): + seen = [] + + def collator(batch): + seen.append(len(batch)) + return {} + + vlm.check_collation(collator, [{"a": i} for i in range(50)]) + assert seen == [2] + + def test_shorter_dataset_than_the_sample_size(self): + """A one-record dataset must not index past the end.""" + vlm.check_collation(lambda batch: {}, [{"a": 1}]) + + def test_empty_dataset_is_a_no_op(self): + vlm.check_collation(lambda batch: 1 / 0, []) diff --git a/backend/tests/lambdas/test_kb_document_reconciler.py b/backend/tests/lambdas/test_kb_document_reconciler.py new file mode 100644 index 000000000..b68ce02e3 --- /dev/null +++ b/backend/tests/lambdas/test_kb_document_reconciler.py @@ -0,0 +1,677 @@ +"""Dead-letter document reconciler — task 16.5, HANDOFF §5.37. + +The reconciler is the missing *second* writer of ``DOC#`` status. The ingestion +consumer is the only writer today, and when its event dead-letters (Lambda async +retry is capped at 2) a document Bedrock finished indexing is left parked +non-terminal forever — and the retrieval filter serves only ``complete``, so its +content is in the knowledge base and invisible to every query. + +Four assertions here are the reason the file exists, and each guards a mistake a +green suite would otherwise hide: + +**A stranded-but-retrievable document is driven to ``complete`` — but only when it +is genuinely retrievable.** The §5.37 fix. Marking it complete on ``INDEXED`` +alone, without confirming a filtered retrieval returns it, recreates the exact +"upload worked but the assistant cannot see it" report the consumer was built to +prevent. Both halves are asserted. + +**Report-only really is a no-op.** The shipped mode plans every action and writes +nothing; the arming flag treats an empty string as off. + +**The grace gate reads the row's own ``updatedAt``, never discovery time**, and +fails closed when it cannot be read — so an in-flight upload is never marked from +under the consumer. + +**Terminal and soft-deleted rows are untouchable.** ``complete``/``failed`` are +done; ``deleting`` is being removed on purpose and must never be resurrected. + +No test contacts AWS. DynamoDB is moto; the managed backend is a stub that models +Bedrock's document view, mirroring ``test_kb_ingestion_consumer``. +""" + +import types +from datetime import datetime, timedelta, timezone + +import boto3 +import pytest +from moto import mock_aws + +from apis.app_api.kb_migration import document_reconciler as dr + +REGION = "us-east-1" +TABLE = "test-doc-reconciler" +NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + + +def _iso(moment): + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +# Ages relative to NOW. +OLD = _iso(NOW - timedelta(hours=2)) # comfortably past the 60-minute gate +YOUNG = _iso(NOW - timedelta(minutes=5)) # still plausibly in flight + + +@pytest.fixture() +def table(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + monkeypatch.setenv("DYNAMODB_ASSISTANTS_TABLE_NAME", TABLE) + # Never inherited from the developer's shell: the reconciler is disarmed unless + # something says otherwise. + monkeypatch.delenv(dr.FLAG_DOC_RECONCILER_ARMED, raising=False) + + with mock_aws(): + boto3.client("dynamodb", region_name=REGION).create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + yield boto3.resource("dynamodb", region_name=REGION).Table(TABLE) + + +@pytest.fixture(autouse=True) +def no_metrics(monkeypatch): + monkeypatch.setattr(dr, "emit_count", lambda *a, **k: None) + + +# ── Fixtures for the table ──────────────────────────────────────────────────── +def _seed_kb(table, assistant_id, *, engine="managed", aws_kb_id="KB1"): + item = { + "PK": f"AST#{assistant_id}", + "SK": f"KB#{assistant_id}", + "appKbId": assistant_id, + } + if engine: + item["retrievalEngine"] = engine + if aws_kb_id: + item["awsKbId"] = aws_kb_id + item["awsDataSourceId"] = f"DS{aws_kb_id}" + table.put_item(Item=item) + + +def _seed_doc(table, assistant_id, document_id, *, status, updated_at=OLD, s3_key=None, filename=None): + item = { + "PK": f"AST#{assistant_id}", + "SK": f"DOC#{document_id}", + "documentId": document_id, + "status": status, + "updatedAt": updated_at, + } + if s3_key is not None: + item["s3Key"] = s3_key + if filename is not None: + item["filename"] = filename + table.put_item(Item=item) + + +def _doc(table, assistant_id, document_id): + return table.get_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"DOC#{document_id}"} + ).get("Item") + + +# ── The stub backend: models Bedrock's document view ───────────────────────── +class _FakeBackend: + """Models the parts of ManagedKbBackend the reconciler leans on. + + ``statuses`` maps document_id -> the status Bedrock's ``GetKnowledgeBaseDocuments`` + reports (INDEXED, FAILED, IN_PROGRESS, TEXT_INDEXED, PARTIALLY_INDEXED, + NOT_FOUND, or anything unrecognised). ``retrievable`` is the set of document ids + a *filtered* retrieval returns — modelling that INDEXED does not imply + retrievable, and that the id-as-query search only works when filtered. + """ + + def __init__(self, statuses=None, retrievable=None, other_documents=("DOC-someone-else",)): + self._statuses = dict(statuses or {}) + self._retrievable = set(retrievable or []) + self._other_documents = list(other_documents) + self.search_filters = [] + self.ingested = [] + self._agent_client = _FakeAgent(self) + + # -- the private surface `ic.document_status` reuses ----------------------- + def _agent(self): + return self._agent_client + + def _locate(self, kb_ref): + return ("KB1", "DS1") + + # -- the protocol surface -------------------------------------------------- + async def ingest(self, kb_ref, source): + self.ingested.append(source.document_id) + + async def search(self, kb_ref, query, top_k=5, retrieval_filter=None): + """Honours an ``equals`` filter on ``document_id``; otherwise ranks badly. + + The unfiltered branch returns the *other* documents — what the real service + did (§5.38): an unfiltered search for a document id returns whatever the + reranker prefers. A reconciler that dropped the filter would confirm the + wrong document as retrievable. + """ + self.search_filters.append(retrieval_filter) + wanted = None + if retrieval_filter: + equals = retrieval_filter.get("equals") or {} + if equals.get("key") == "document_id": + wanted = equals.get("value") + + if wanted is not None: + doc_ids = [wanted] if wanted in self._retrievable else [] + else: + doc_ids = list(self._other_documents) + + return [types.SimpleNamespace(metadata={"document_id": d}) for d in doc_ids] + + +class _FakeAgent: + def __init__(self, owner): + self._owner = owner + + def get_knowledge_base_documents(self, **kwargs): + identifiers = kwargs.get("documentIdentifiers") or [{}] + doc_id = (identifiers[0].get("custom") or {}).get("id") + status = self._owner._statuses.get(doc_id, "NOT_FOUND") + if status == "NOT_FOUND": + return {"documentDetails": []} + return { + "documentDetails": [ + { + "status": status, + "identifier": {"dataSourceType": "CUSTOM", "custom": {"id": doc_id}}, + "updatedAt": datetime(2026, 5, 30, tzinfo=timezone.utc), + } + ] + } + + +def _run(table, backend, **kwargs): + """Run a pass with an injected backend factory returning ``backend``.""" + kwargs.setdefault("now", NOW) + kwargs.setdefault("backend_factory", lambda _assistant_id: backend) + return dr.reconcile_documents(**kwargs) + + +# ── The arming flag ────────────────────────────────────────────────────────── +class TestArmingFlag: + @pytest.mark.parametrize("value", ["", " ", "0", "false", "False", "off", "no", "disabled"]) + def test_falsy_and_empty_values_are_off(self, monkeypatch, value): + monkeypatch.setenv(dr.FLAG_DOC_RECONCILER_ARMED, value) + assert dr.doc_reconciler_armed() is False + + def test_unset_is_off(self, monkeypatch): + monkeypatch.delenv(dr.FLAG_DOC_RECONCILER_ARMED, raising=False) + assert dr.doc_reconciler_armed() is False + + @pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", "enabled", " true "]) + def test_affirmative_values_arm(self, monkeypatch, value): + monkeypatch.setenv(dr.FLAG_DOC_RECONCILER_ARMED, value) + assert dr.doc_reconciler_armed() is True + + def test_reconcile_defaults_to_the_flag(self, table, monkeypatch): + monkeypatch.setenv(dr.FLAG_DOC_RECONCILER_ARMED, "") + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading") + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable={"doc-1"}) + + report = _run(table, backend) # note: no armed= override, so it reads the flag + + assert report.armed is False + assert report.to_dict()["mode"] == "report-only" + + +# ── The grace gate ─────────────────────────────────────────────────────────── +class TestGraceGate: + def test_a_recently_updated_document_is_left_alone(self, table): + """TRAP: acting on a young row races an ingestion still in flight.""" + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-young", status="uploading", updated_at=YOUNG) + backend = _FakeBackend(statuses={"doc-young": "INDEXED"}, retrievable={"doc-young"}) + + report = _run(table, backend, armed=True) + + assert report.skipped_too_young == ["doc-young"] + assert report.planned_actions == [] + assert _doc(table, "ast-1", "doc-young")["status"] == "uploading" + + def test_a_long_stuck_document_is_reconciled(self, table): + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-old", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-old": "INDEXED"}, retrievable={"doc-old"}) + + report = _run(table, backend, armed=True) + + assert report.skipped_too_young == [] + assert report.stranded == 1 + + def test_missing_or_unparseable_updated_at_fails_closed(self): + assert dr.document_is_stuck_long_enough(None, now=NOW) is False + assert dr.document_is_stuck_long_enough("not-a-date", now=NOW) is False + + def test_the_gate_is_a_pure_function_of_updated_at(self): + old = NOW - timedelta(hours=2) + young = NOW - timedelta(minutes=5) + assert dr.document_is_stuck_long_enough(_iso(old), now=NOW) is True + assert dr.document_is_stuck_long_enough(_iso(young), now=NOW) is False + # Same answer regardless of when it is asked — the property a discovery-time + # clock does not have. + assert dr.document_is_stuck_long_enough(_iso(old), now=NOW + timedelta(days=9)) is True + + def test_min_age_is_read_at_call_time(self, monkeypatch): + stamped = NOW - timedelta(minutes=30) + assert dr.document_is_stuck_long_enough(_iso(stamped), now=NOW) is False + monkeypatch.setattr(dr, "STUCK_MIN_AGE_MINUTES", 10.0) + assert dr.document_is_stuck_long_enough(_iso(stamped), now=NOW) is True + + +# ── The §5.37 fix: stranded-but-retrievable → complete ─────────────────────── +class TestMarkComplete: + def test_a_stranded_retrievable_document_is_completed_when_armed(self, table): + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="embedding", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable={"doc-1"}) + + report = _run(table, backend, armed=True) + + assert [a.kind for a in report.planned_actions] == [dr.ACTION_MARK_COMPLETE] + assert report.actions_performed == 1 + row = _doc(table, "ast-1", "doc-1") + assert row["status"] == "complete" + assert row["retrievableAt"] + assert row["indexedAt"] + + def test_indexed_but_not_retrievable_is_left_short_of_complete(self, table): + """MUTATION GUARD: dropping the retrievability check would complete this. + + Bedrock says INDEXED, but a filtered retrieval returns nothing — so the + content is not yet queryable. Marking it complete here is the exact bug the + consumer's retrievability poll exists to prevent. + """ + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable=set()) + + report = _run(table, backend, armed=True) + + assert report.planned_actions == [] + assert report.skipped_not_retrievable == ["doc-1"] + assert _doc(table, "ast-1", "doc-1")["status"] == "uploading" + + def test_partially_indexed_and_retrievable_is_completed(self, table): + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="chunking", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "PARTIALLY_INDEXED"}, retrievable={"doc-1"}) + + report = _run(table, backend, armed=True) + + assert [a.kind for a in report.planned_actions] == [dr.ACTION_MARK_COMPLETE] + assert _doc(table, "ast-1", "doc-1")["status"] == "complete" + + def test_report_only_plans_but_writes_nothing(self, table): + """MUTATION GUARD: report-only must plan the action and perform none of it.""" + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable={"doc-1"}) + + report = _run(table, backend, armed=False) + + assert [a.kind for a in report.planned_actions] == [dr.ACTION_MARK_COMPLETE] + assert report.actions_performed == 0 + assert _doc(table, "ast-1", "doc-1")["status"] == "uploading" + assert backend.ingested == [] + + +# ── Bedrock FAILED → failed ────────────────────────────────────────────────── +class TestMarkFailed: + @pytest.mark.parametrize("bedrock_status", ["FAILED", "METADATA_UPDATE_FAILED"]) + def test_a_failed_document_is_marked_failed_when_armed(self, table, bedrock_status): + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="embedding", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": bedrock_status}) + + report = _run(table, backend, armed=True) + + assert [a.kind for a in report.planned_actions] == [dr.ACTION_MARK_FAILED] + row = _doc(table, "ast-1", "doc-1") + assert row["status"] == "failed" + assert row.get("ingestionError") + + def test_a_failed_document_is_not_confused_for_retrievable(self, table): + """A FAILED document must never be probed for retrievability and completed.""" + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "FAILED"}, retrievable={"doc-1"}) + + report = _run(table, backend, armed=True) + + assert [a.kind for a in report.planned_actions] == [dr.ACTION_MARK_FAILED] + assert _doc(table, "ast-1", "doc-1")["status"] == "failed" + + +# ── Bedrock NOT_FOUND → re-ingest (task 14.4 overlap) ──────────────────────── +class TestReIngest: + def test_a_not_found_document_is_re_ingested_when_armed(self, table): + _seed_kb(table, "ast-1") + _seed_doc( + table, "ast-1", "doc-1", status="uploading", updated_at=OLD, + s3_key="assistants/ast-1/documents/doc-1/report.pdf", filename="report.pdf", + ) + backend = _FakeBackend(statuses={"doc-1": "NOT_FOUND"}) + + report = _run(table, backend, armed=True) + + assert [a.kind for a in report.planned_actions] == [dr.ACTION_RE_INGEST] + assert report.actions_performed == 1 + assert backend.ingested == ["doc-1"] + # Re-ingest re-fires the pipeline; the consumer drives it to complete, so + # the reconciler leaves the row non-terminal rather than claiming success. + assert _doc(table, "ast-1", "doc-1")["status"] == "uploading" + + def test_report_only_does_not_re_ingest(self, table): + _seed_kb(table, "ast-1") + _seed_doc( + table, "ast-1", "doc-1", status="uploading", updated_at=OLD, + s3_key="assistants/ast-1/documents/doc-1/report.pdf", filename="report.pdf", + ) + backend = _FakeBackend(statuses={"doc-1": "NOT_FOUND"}) + + report = _run(table, backend, armed=False) + + assert [a.kind for a in report.planned_actions] == [dr.ACTION_RE_INGEST] + assert backend.ingested == [] + + def test_not_found_without_an_s3_key_is_not_re_ingested(self, table): + """An old row with no s3Key cannot be re-ingested from here.""" + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading", updated_at=OLD) # no s3Key + backend = _FakeBackend(statuses={"doc-1": "NOT_FOUND"}) + + report = _run(table, backend, armed=True) + + assert report.planned_actions == [] + assert backend.ingested == [] + assert report.skipped_not_retrievable == ["doc-1"] + + +# ── In-flight and unknown statuses are left alone ───────────────────────────── +class TestLeftAlone: + @pytest.mark.parametrize("bedrock_status", ["STARTING", "PENDING", "IN_PROGRESS", "TEXT_INDEXED"]) + def test_in_flight_documents_are_left_alone(self, table, bedrock_status): + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": bedrock_status}) + + report = _run(table, backend, armed=True) + + assert report.planned_actions == [] + assert report.skipped_in_flight == ["doc-1"] + assert _doc(table, "ast-1", "doc-1")["status"] == "uploading" + + def test_an_unknown_bedrock_status_is_treated_as_in_flight(self, table): + """§5.39: the live service returns statuses the SDK enum omits. Unknown + must mean 'keep waiting', not 'give up'.""" + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "SOME_NEW_STATUS_AWS_ADDED"}) + + report = _run(table, backend, armed=True) + + assert report.planned_actions == [] + assert report.skipped_in_flight == ["doc-1"] + + +# ── Terminal and soft-deleted rows are untouchable ─────────────────────────── +class TestTerminalRowsIgnored: + @pytest.mark.parametrize("status", ["complete", "failed", "deleting"]) + def test_terminal_and_deleting_rows_are_never_candidates(self, table, status): + """MUTATION GUARD: NON_TERMINAL_STATUSES must exclude these. + + ``deleting`` is the dangerous one — a soft-deleted document driven back to + ``complete`` would resurrect content the user removed. + """ + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status=status, updated_at=OLD) + # Bedrock would say INDEXED+retrievable, which WOULD complete a candidate. + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable={"doc-1"}) + + report = _run(table, backend, armed=True) + + assert report.stranded == 0 + assert report.planned_actions == [] + assert _doc(table, "ast-1", "doc-1")["status"] == status + + +# ── Engine and provisioning scoping ────────────────────────────────────────── +class TestScoping: + def test_legacy_assistant_documents_are_ignored(self, table): + """A record with no retrievalEngine is legacy; its DOC# status is owned by + the legacy pipeline, not this reconciler.""" + _seed_kb(table, "ast-legacy", engine=None, aws_kb_id=None) + _seed_doc(table, "ast-legacy", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable={"doc-1"}) + + report = _run(table, backend, armed=True) + + assert report.managed_records == 0 + assert report.documents_scanned == 0 + assert report.planned_actions == [] + + def test_an_unprovisioned_managed_record_is_skipped(self, table): + """Managed but no awsKbId: there is no knowledge base to probe yet.""" + _seed_kb(table, "ast-prov", engine="managed", aws_kb_id=None) + _seed_doc(table, "ast-prov", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable={"doc-1"}) + + report = _run(table, backend, armed=True) + + assert report.managed_records == 0 + assert report.planned_actions == [] + + +# ── Per-run action limit ───────────────────────────────────────────────────── +class TestPerRunActionLimit: + def _five_stranded(self, table): + _seed_kb(table, "ast-1") + statuses, retrievable = {}, set() + for i in range(5): + _seed_doc(table, "ast-1", f"doc-{i}", status="uploading", updated_at=OLD) + statuses[f"doc-{i}"] = "INDEXED" + retrievable.add(f"doc-{i}") + return _FakeBackend(statuses=statuses, retrievable=retrievable) + + def test_the_limit_caps_planned_actions_in_report_only_mode(self, table, monkeypatch): + monkeypatch.setattr(dr, "MAX_ACTIONS_PER_RUN", 2) + backend = self._five_stranded(table) + + report = _run(table, backend, armed=False) + + assert len(report.planned_actions) == 2 + assert report.limit_reached is True + + def test_the_limit_caps_actual_actions_when_armed(self, table, monkeypatch): + monkeypatch.setattr(dr, "MAX_ACTIONS_PER_RUN", 2) + backend = self._five_stranded(table) + + report = _run(table, backend, armed=True) + + assert report.actions_performed == 2 + assert report.limit_reached is True + completed = sum( + 1 for i in range(5) if (_doc(table, "ast-1", f"doc-{i}") or {})["status"] == "complete" + ) + assert completed == 2 + + def test_the_environment_can_lower_the_limit_but_not_lift_it(self, monkeypatch): + monkeypatch.setenv("MANAGED_KB_DOC_RECONCILER_MAX_ACTIONS", "3") + assert dr.max_actions_per_run() == 3 + monkeypatch.setenv("MANAGED_KB_DOC_RECONCILER_MAX_ACTIONS", "1000000") + assert dr.max_actions_per_run() == dr.MAX_ACTIONS_CEILING + + def test_a_negative_limit_does_not_become_unbounded(self, monkeypatch): + monkeypatch.setenv("MANAGED_KB_DOC_RECONCILER_MAX_ACTIONS", "-5") + assert dr.max_actions_per_run() == 0 + + def test_the_limit_is_read_at_call_time(self, monkeypatch): + assert dr.max_actions_per_run() == dr.MAX_ACTIONS_PER_RUN + monkeypatch.setattr(dr, "MAX_ACTIONS_PER_RUN", 3) + assert dr.max_actions_per_run() == 3 + + +# ── Retrievability probe (§5.38) ───────────────────────────────────────────── +class TestRetrievabilityProbe: + def test_it_filters_on_document_id_by_equals(self, table): + """MUTATION GUARD: an unfiltered search returns the wrong document (§5.38). + + The backend returns OTHER documents when unfiltered. ``is_retrievable`` must + pass the ``equals`` filter, so a document not in the retrievable set returns + False even though the search would otherwise return chunks. + """ + backend = _FakeBackend(retrievable=set()) # nothing is retrievable + + assert dr.is_retrievable(backend, "ast-1", "doc-1") is False + assert backend.search_filters == [{"equals": {"key": "document_id", "value": "doc-1"}}] + + def test_it_returns_true_only_for_its_own_document(self, table): + backend = _FakeBackend(retrievable={"doc-1"}) + assert dr.is_retrievable(backend, "ast-1", "doc-1") is True + assert dr.is_retrievable(backend, "ast-1", "doc-other") is False + + def test_a_probe_error_is_not_a_positive(self): + class Boom: + async def search(self, *a, **k): + raise RuntimeError("retrieve failed") + + assert dr.is_retrievable(Boom(), "ast-1", "doc-1") is False + + +# ── The lambda handler ─────────────────────────────────────────────────────── +class TestLambdaHandler: + @pytest.fixture() + def one_stranded(self, table, monkeypatch): + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-1", status="uploading", updated_at=OLD) + backend = _FakeBackend(statuses={"doc-1": "INDEXED"}, retrievable={"doc-1"}) + # lambda_handler builds its own backend via the default factory, which we + # cannot inject through the event — so patch the factory builder. + monkeypatch.setattr(dr, "_default_backend_factory", lambda client: (lambda _a: backend)) + # And pin 'now' well ahead of the row's OLD stamp is unnecessary: OLD is + # relative to a fixed 2026 date, comfortably older than real wall-clock. + return backend + + def test_it_returns_the_serialized_report(self, table, monkeypatch): + monkeypatch.setattr( + dr, "reconcile_documents", lambda **kw: dr.DocumentReconcileReport(armed=False) + ) + result = dr.lambda_handler({}, None) + assert result["statusCode"] == 200 + assert result["report"]["mode"] == "report-only" + + @pytest.mark.parametrize("payload", [True, "true", 1, "1", "yes"]) + def test_the_event_cannot_arm_the_reconciler(self, table, one_stranded, payload): + result = dr.lambda_handler({"armed": payload}, None) + assert result["report"]["mode"] == "report-only" + assert result["report"]["actionsPerformed"] == 0 + assert one_stranded.ingested == [] + assert _doc(table, "ast-1", "doc-1")["status"] == "uploading" + # The finding is still reported — suppressing the write did not suppress it. + assert result["report"]["stranded"] == 1 + + def test_the_flag_is_what_arms_it(self, table, one_stranded, monkeypatch): + monkeypatch.setenv(dr.FLAG_DOC_RECONCILER_ARMED, "true") + result = dr.lambda_handler({"armed": False}, None) + assert result["report"]["mode"] == "armed" + assert result["report"]["actionsPerformed"] == 1 + assert _doc(table, "ast-1", "doc-1")["status"] == "complete" + + def test_an_ignored_arming_request_is_logged(self, table, one_stranded, caplog): + import logging + + with caplog.at_level(logging.WARNING): + dr.lambda_handler({"armed": True}, None) + assert any( + "ignoring armed" in r.message and dr.FLAG_DOC_RECONCILER_ARMED in r.message + for r in caplog.records + ) + + +# ── Mixed and degenerate cases ─────────────────────────────────────────────── +class TestMixedRun: + def test_all_outcomes_in_one_pass(self, table): + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "d-complete", status="uploading", updated_at=OLD) + _seed_doc(table, "ast-1", "d-failed", status="embedding", updated_at=OLD) + _seed_doc( + table, "ast-1", "d-reingest", status="uploading", updated_at=OLD, + s3_key="assistants/ast-1/documents/d-reingest/x.pdf", filename="x.pdf", + ) + _seed_doc(table, "ast-1", "d-inflight", status="chunking", updated_at=OLD) + _seed_doc(table, "ast-1", "d-young", status="uploading", updated_at=YOUNG) + _seed_doc(table, "ast-1", "d-done", status="complete", updated_at=OLD) + backend = _FakeBackend( + statuses={ + "d-complete": "INDEXED", + "d-failed": "FAILED", + "d-reingest": "NOT_FOUND", + "d-inflight": "IN_PROGRESS", + }, + retrievable={"d-complete"}, + ) + + report = _run(table, backend, armed=True) + + kinds = {a.document_id: a.kind for a in report.planned_actions} + assert kinds == { + "d-complete": dr.ACTION_MARK_COMPLETE, + "d-failed": dr.ACTION_MARK_FAILED, + "d-reingest": dr.ACTION_RE_INGEST, + } + assert report.skipped_in_flight == ["d-inflight"] + assert report.skipped_too_young == ["d-young"] + assert _doc(table, "ast-1", "d-complete")["status"] == "complete" + assert _doc(table, "ast-1", "d-failed")["status"] == "failed" + assert _doc(table, "ast-1", "d-done")["status"] == "complete" # untouched + + def test_an_empty_table_is_a_clean_no_op(self, table): + backend = _FakeBackend() + report = _run(table, backend, armed=True) + payload = report.to_dict() + assert payload["managedRecords"] == 0 + assert payload["stranded"] == 0 + assert payload["plannedActions"] == [] + assert payload["actionsPerformed"] == 0 + + def test_a_failing_write_does_not_end_the_run(self, table, monkeypatch): + """One bad document must not stop the reconciler reaching the others.""" + _seed_kb(table, "ast-1") + _seed_doc(table, "ast-1", "doc-a", status="uploading", updated_at=OLD) + _seed_doc(table, "ast-1", "doc-b", status="uploading", updated_at=OLD) + backend = _FakeBackend( + statuses={"doc-a": "INDEXED", "doc-b": "INDEXED"}, + retrievable={"doc-a", "doc-b"}, + ) + + calls = {"n": 0} + real = dr.ic.set_document_terminal + + def flaky(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("dynamo throttled") + return real(*args, **kwargs) + + monkeypatch.setattr(dr.ic, "set_document_terminal", flaky) + + report = _run(table, backend, armed=True) + + assert len(report.planned_actions) == 2 + assert report.actions_performed == 1 + assert any(a.error for a in report.planned_actions) diff --git a/backend/tests/routes/test_kb_upgrade.py b/backend/tests/routes/test_kb_upgrade.py index 19f24c844..2df285fcb 100644 --- a/backend/tests/routes/test_kb_upgrade.py +++ b/backend/tests/routes/test_kb_upgrade.py @@ -287,6 +287,52 @@ async def test_an_unreadable_document_list_fails_loudly_here( await s.get_upgrade_status(ASSISTANT_ID, can_edit=True) +# ── Engine visibility (task 16.4, HANDOFF §6) ──────────────────────────────── +class TestEngineBadge: + """Every status response carries the engine the badge renders. + + Present on ``none`` too, because the badge is engine visibility, not upgrade + state — a settled legacy knowledge base still shows "Classic". Absent/legacy + collapses to ``classic``, matching the retrieval resolver's default. + """ + + @pytest.mark.asyncio + async def test_a_promoted_kb_reports_engine_managed(self, table): + table.item = _kb_record( + retrievalEngine=r.ENGINE_MANAGED, migrationState=r.RETAIN + ) + status = await s.get_upgrade_status(ASSISTANT_ID, can_edit=True) + assert status.engine == "managed" + + @pytest.mark.asyncio + async def test_a_legacy_kb_with_documents_reports_engine_classic(self, table): + table.docs = [_doc("d1")] + status = await s.get_upgrade_status(ASSISTANT_ID, can_edit=True) + assert status.phase == "available" + assert status.engine == "classic" + + @pytest.mark.asyncio + async def test_an_in_progress_migration_is_still_classic_until_promoted(self, table): + # The record exists but has not been promoted, so it is still served by + # the classic engine — the badge must not jump ahead of the promotion. + table.item = _kb_record(migrationState=r.SHADOW) + status = await s.get_upgrade_status(ASSISTANT_ID, can_edit=True) + assert status.phase == "in_progress" + assert status.engine == "classic" + + @pytest.mark.asyncio + async def test_an_absent_record_reports_engine_classic(self, table): + table.item = None + table.docs = [] + status = await s.get_upgrade_status(ASSISTANT_ID, can_edit=True) + assert status.phase == "none" + assert status.engine == "classic" + + def test_engine_defaults_to_classic_on_the_wire_model(self): + """A response built without an engine is classic, not blank.""" + assert m.UpgradeStatusResponse(phase="none").engine == "classic" + + # ── Stranded documents (Requirement 21) ────────────────────────────────────── class TestStrandedDocuments: def test_complete_documents_are_not_flagged(self): @@ -673,6 +719,7 @@ def test_status_serialises_camel_case(self): assert "canUpgrade" in payload assert "noticePending" in payload assert "documentsNotCarried" in payload + assert payload["engine"] == "classic" def test_stranded_document_serialises_camel_case(self): payload = m.DocumentNotCarried( diff --git a/backend/tests/security/test_calculator_sandbox.py b/backend/tests/security/test_calculator_sandbox.py new file mode 100644 index 000000000..00037acbc --- /dev/null +++ b/backend/tests/security/test_calculator_sandbox.py @@ -0,0 +1,101 @@ +"""Regression tests for the sandbox in the vendored ``strands_tools.calculator``. + +``calculator`` is registered in ``create_default_registry()`` and seeded with +``enabledByDefault=True``, so it is reachable by every user on every agent and +it evaluates model-supplied expressions. Its AST allowlist is therefore a real +security boundary, not an input-validation nicety. + +The boundary permits a string literal only as a positional argument to a small +set of constructors that parse it as a plain name or numeric literal +(``Symbol``, ``symbols``, ``Rational``, ``Integer``, ``Float``); everywhere else +a string is rejected, which blocks the ``sympify``-backed re-parse escape. + +Up to ``strands-agents-tools`` 0.8.6 that check ignored the call's keyword +arguments, so ``symbols('...', cls=N)`` rerouted ``symbols`` to apply ``N`` — +and therefore ``sympify`` — to the string, re-parsing it outside the restricted +namespace. 0.8.8 trusts a string literal only when every keyword on the call is +a boolean assumption flag, and treats ``**kwargs`` unpacking as untrusted +because it can smuggle in ``cls``. + +These tests pin that behaviour to the installed wheel, so a downgrade or a +resolver drift back below 0.8.8 fails the suite instead of silently reopening +the escape. +""" + +from __future__ import annotations + +import pytest + +from strands_tools.calculator import _validate_expression_ast, parse_expression + +# --------------------------------------------------------------------------- +# The escape: a keyword that reroutes how the string argument is parsed. +# --------------------------------------------------------------------------- + +REROUTED_STRING_ARGS = [ + # The disclosed form: cls=N makes symbols apply N (sympify) to the string. + "symbols('x', cls=N)", + # The same reroute carrying a payload that must never reach sympify. + "symbols('__import__(\"os\").system(\"id\")', cls=N)", + # cls on the other string constructors is rejected for the same reason. + "Symbol('1+1', cls=N)", + # **kwargs unpacking can smuggle in cls, so it is untrusted too. + "symbols('x', **kw)", + # A non-boolean keyword is not an assumption flag. + "symbols('x', cls=Float)", +] + + +@pytest.mark.parametrize("expression", REROUTED_STRING_ARGS) +def test_string_arg_with_rerouting_keyword_rejected(expression: str) -> None: + """A string literal is not trusted when the call carries a rerouting keyword.""" + with pytest.raises(ValueError, match="string literals are not supported"): + parse_expression(expression) + + +def test_string_arg_outside_safe_constructors_rejected() -> None: + """The pre-existing half of the boundary: sympify-backed constructors stay closed.""" + with pytest.raises(ValueError, match="string literals are not supported"): + parse_expression("N('1+1')") + + +# --------------------------------------------------------------------------- +# The fix must not narrow legitimate use — calculator is on for every user. +# --------------------------------------------------------------------------- + +LEGITIMATE_EXPRESSIONS = [ + "2 + 2 * 10", + "x**2 + 2*x + 1", + "sin(pi/2) + log(E)", + "Symbol('x')", + "symbols('x y')", + "Rational('1/3')", + "Integer('42')", + "Float('3.14')", +] + + +@pytest.mark.parametrize("expression", LEGITIMATE_EXPRESSIONS) +def test_ordinary_expressions_still_parse(expression: str) -> None: + """Ordinary arithmetic and symbolic input are unaffected by the tightened check.""" + assert parse_expression(expression) is not None + + +ASSUMPTION_KEYWORD_CALLS = [ + "Symbol('x', positive=True)", + "Symbol('x', real=True, positive=True)", + "symbols('x y', positive=True)", +] + + +@pytest.mark.parametrize("expression", ASSUMPTION_KEYWORD_CALLS) +def test_assumption_keywords_remain_trusted(expression: str) -> None: + """Boolean assumption flags do not reroute parsing, so the string stays trusted. + + Asserted against the validator rather than ``parse_expression`` because these + calls fail further downstream for an unrelated, pre-existing reason: sympy's + ``implicit_multiplication_application`` transform rewrites ``positive=True`` + into a multiplication before ``parse_expr`` sees it. That behaviour is + identical on 0.8.6 and 0.8.8 — the security boundary is the layer under test. + """ + _validate_expression_ast(expression) diff --git a/backend/tests/shared/test_kb_backend_parity.py b/backend/tests/shared/test_kb_backend_parity.py index f1dec3154..5f60be192 100644 --- a/backend/tests/shared/test_kb_backend_parity.py +++ b/backend/tests/shared/test_kb_backend_parity.py @@ -19,6 +19,7 @@ """ import asyncio +import logging from typing import Any, Dict, List from unittest.mock import MagicMock, patch @@ -26,8 +27,10 @@ from apis.shared.assistants.kb_access import granted from apis.shared.assistants.rag_service import ( + MANAGED_MAX_CONTEXT_CHARS, MAX_CONTEXT_CHARS, augment_prompt_with_context, + resolve_context_cap, search_assistant_knowledgebase_with_formatting, ) from apis.shared.kb_backend.protocol import ( @@ -173,6 +176,53 @@ def test_managed_path_requests_top_k_five(managed_kb): assert backend.calls, "the managed backend was never reached" assert backend.calls[0]["top_k"] == DEFAULT_TOP_K + + +# --------------------------------------------------------------------------- +# Requirement 3.2 — the context cap is engine-aware (amended 2026-09-04, §5.40) +# --------------------------------------------------------------------------- +# +# The cap is NO LONGER identical across backends, and that is the fix, not a +# regression. Bedrock's chunks are ~3x the Docling chunks 2,000 was sized for, so +# a single character cap silently admitted 4 legacy chunks and 1 managed chunk — +# top_k=5 became top_k=1 at the model, with wrong answers to show for it. The cap +# is now per engine, and these guards pin the two values apart. Measured before/ +# after on the KINES advising corpus (dev ast-1d51df6ea532): at 2,000 the model +# described 1 of 4 emphasis areas and guessed the rest from outside knowledge; +# at 8,000 all four came from the documents. + + +def test_managed_gets_the_eight_thousand_char_cap(): + """A managed knowledge base's context cap is 8,000. + + Pinned to the literal, not to ``MANAGED_MAX_CONTEXT_CHARS`` — that number is a + property of Bedrock's chunk sizing measured on a real corpus (eval §13.6, + HANDOFF §5.40), so a silent edit to the constant must fail here rather than + follow it (HANDOFF §4: never assert a constant against itself). + """ + assert resolve_context_cap(ASSISTANT_ID, record={"retrievalEngine": ENGINE_MANAGED}) == 8000 + + +def test_legacy_keeps_the_two_thousand_char_cap(): + """An absent/legacy record resolves to the historical 2,000 cap, unchanged.""" + assert resolve_context_cap(ASSISTANT_ID, record={}) == 2000 + + +def test_the_managed_and_legacy_caps_are_distinct(): + """Guard against the two caps collapsing to one value — the mutation that + reintroduces §5.40 by making managed inherit the 2,000 figure again.""" + assert MANAGED_MAX_CONTEXT_CHARS == 8000 + assert MAX_CONTEXT_CHARS == 2000 + assert MANAGED_MAX_CONTEXT_CHARS != MAX_CONTEXT_CHARS + + +def test_context_cap_keys_on_the_same_kb_record_read_as_the_backend(): + """With no record passed, the cap reads the KB_Record itself and gets 8,000 + for a managed assistant — the SAME read ``resolve_backend`` uses, so the cap + and the served engine can never disagree.""" + boto_patch, _ = _patch_record_and_statuses({}) + with patch.dict("os.environ", {"DYNAMODB_ASSISTANTS_TABLE_NAME": TABLE_NAME}), boto_patch: + assert resolve_context_cap(ASSISTANT_ID) == 8000 assert DEFAULT_TOP_K == 5, "the parity contract pins top_k at 5" @@ -618,3 +668,74 @@ def _get_item(**kwargs): def test_fake_managed_backend_conforms_to_protocol(): assert isinstance(FakeManagedBackend([]), KnowledgeBaseBackend) + + +# --------------------------------------------------------------------------- +# Engine visibility (task 16.4, HANDOFF §6) — one INFO line per query naming +# the engine that served it. The resolver otherwise logs only on failure, so +# without this line "is the managed backend actually serving?" is answerable +# only from the KB record. These pin the line's presence and its content, so +# deleting it or mislabelling the engine fails a named test. +# --------------------------------------------------------------------------- + +_RAG_LOGGER = "apis.shared.assistants.rag_service" + + +def _engine_log_lines(caplog) -> List[str]: + return [r.getMessage() for r in caplog.records if "served by engine=" in r.getMessage()] + + +def test_managed_query_logs_the_serving_engine(managed_kb, caplog): + """A managed query emits exactly one INFO line naming ``managed`` / Managed.""" + managed_kb([_managed_chunk("doc-a", 0)]) + boto_patch, _ = _patch_record_and_statuses({"doc-a": "complete"}) + + with ( + patch.dict("os.environ", {"DYNAMODB_ASSISTANTS_TABLE_NAME": TABLE_NAME}), + boto_patch, + caplog.at_level(logging.INFO, logger=_RAG_LOGGER), + ): + asyncio.run( + search_assistant_knowledgebase_with_formatting(ASSISTANT_ID, "q", access=OWNER_ACCESS) + ) + + lines = _engine_log_lines(caplog) + assert len(lines) == 1, f"expected exactly one engine line, got {lines}" + assert "engine=managed" in lines[0] + assert "(Managed)" in lines[0] + assert ASSISTANT_ID in lines[0] + + +def test_legacy_query_logs_the_serving_engine(caplog): + """A legacy (absent-record) query names ``s3vectors`` / Classic.""" + boto_patch, _ = _patch_legacy_record_and_statuses({"doc-a": "complete"}) + + with ( + patch.dict("os.environ", {"DYNAMODB_ASSISTANTS_TABLE_NAME": TABLE_NAME}), + boto_patch, + patch( + "apis.shared.embeddings.bedrock_embeddings.search_assistant_knowledgebase", + return_value=_s3_response( + [{"key": "doc-a#0", "distance": 0.2, "metadata": {"document_id": "doc-a", "text": "legacy"}}] + ), + ), + caplog.at_level(logging.INFO, logger=_RAG_LOGGER), + ): + asyncio.run( + search_assistant_knowledgebase_with_formatting(ASSISTANT_ID, "q", access=OWNER_ACCESS) + ) + + lines = _engine_log_lines(caplog) + assert len(lines) == 1, f"expected exactly one engine line, got {lines}" + assert "engine=s3vectors" in lines[0] + assert "(Classic)" in lines[0] + + +def test_the_engine_line_is_not_emitted_when_access_is_denied(caplog): + """No grant ⇒ no backend contacted ⇒ no engine line, since none served.""" + with caplog.at_level(logging.INFO, logger=_RAG_LOGGER): + results = asyncio.run( + search_assistant_knowledgebase_with_formatting(ASSISTANT_ID, "q", access=None) + ) + assert results == [] + assert _engine_log_lines(caplog) == [] diff --git a/backend/tests/shared/test_mcp_app_error_envelope.py b/backend/tests/shared/test_mcp_app_error_envelope.py new file mode 100644 index 000000000..0f239b17c --- /dev/null +++ b/backend/tests/shared/test_mcp_app_error_envelope.py @@ -0,0 +1,156 @@ +"""Guards for the app-tool error envelope (AgentCore 424 flattening). + +inference-api runs behind AgentCore Runtime, which rewrites any non-2xx to a +generic 424 and discards the message. These tests pin the 200 + envelope +workaround and, most importantly, that a malformed upstream can never make +app-api emit a 401 (which would sign the user out). +""" + +from __future__ import annotations + +import json + +import pytest + +from apis.shared.mcp_apps.error_envelope import ( + ENVELOPE_KEY, + app_tool_error_body, + app_tool_error_response, + build_error_envelope, + read_error_envelope, +) + + +def test_round_trips_message_and_status() -> None: + payload = build_error_envelope( + "Authorization required for 'google_tasks'. Connect the account, " + "then try again.", + 409, + ) + assert read_error_envelope(payload) == ( + "Authorization required for 'google_tasks'. Connect the account, " + "then try again.", + 409, + ) + + +@pytest.mark.parametrize("code", [400, 403, 404, 409, 422, 502]) +def test_relayable_statuses_pass_through(code: int) -> None: + assert read_error_envelope(build_error_envelope("nope", code)) == ( + "nope", + code, + ) + + +def test_ordinary_result_is_not_an_envelope() -> None: + # A successful dispatch payload must relay unchanged. + assert read_error_envelope({"toolUseId": "tu-1", "result": {}}) is None + + +def test_plain_error_body_is_not_an_envelope() -> None: + # A direct (non-AgentCore) inference-api error keeps its own status, + # so app-api must not mistake it for an envelope. + assert read_error_envelope({"error": "boom"}) is None + + +def test_non_dict_payload_is_not_an_envelope() -> None: + assert read_error_envelope(["not", "a", "dict"]) is None + assert read_error_envelope(None) is None + + +def test_non_dict_envelope_value_is_ignored() -> None: + assert read_error_envelope({ENVELOPE_KEY: "not-a-dict"}) is None + + +# --- the safety property: never let upstream choose a 401 ------------------- + + +def test_401_is_never_relayed() -> None: + """A 401 would trip the SPA's interceptor and sign the user out.""" + message, status = read_error_envelope(build_error_envelope("nope", 401)) + assert status == 502 + assert message == "nope" + + +@pytest.mark.parametrize("code", [401, 301, 500, 200, -1, 999]) +def test_unlisted_status_collapses_to_502(code: int) -> None: + _, status = read_error_envelope(build_error_envelope("nope", code)) + assert status == 502 + + +@pytest.mark.parametrize("code", [None, "abc", {"a": 1}, [409]]) +def test_malformed_status_collapses_to_502(code: object) -> None: + _, status = read_error_envelope({ENVELOPE_KEY: {"message": "m", "code": code}}) + assert status == 502 + + +def test_numeric_string_status_is_coerced_then_whitelisted() -> None: + # Coercible, so it is honoured — but still checked against the + # whitelist, so a coercible 401 is no more relayable than an int one. + assert read_error_envelope({ENVELOPE_KEY: {"message": "m", "code": "409"}}) == ( + "m", + 409, + ) + _, status = read_error_envelope({ENVELOPE_KEY: {"message": "m", "code": "401"}}) + assert status == 502 + + +@pytest.mark.parametrize("message", [None, "", " ", 42]) +def test_malformed_message_falls_back(message: object) -> None: + text, _ = read_error_envelope( + {ENVELOPE_KEY: {"message": message, "code": 409}} + ) + assert text == "Tool call failed" + + +# --- the response helper inference-api actually returns --------------------- + + +def test_error_response_is_http_200() -> None: + """The 200 is the fix. + + Returning the real status here is exactly what AgentCore Runtime + flattens into a 424 with the message discarded — the bug this envelope + exists to route around. If this assertion ever fails, the consent + message stops reaching users. + """ + resp = app_tool_error_response("connect the account", 409) + assert resp.status_code == 200 + + +def test_error_response_body_carries_code_and_message() -> None: + resp = app_tool_error_response("connect the account", 409) + assert json.loads(resp.body) == { + ENVELOPE_KEY: {"code": 409, "message": "connect the account"} + } + + +def test_error_response_round_trips_through_the_reader() -> None: + resp = app_tool_error_response("connect the account", 409) + assert read_error_envelope(json.loads(resp.body)) == ( + "connect the account", + 409, + ) + + +# --- the body the SPA's two consumers actually read ------------------------ + + +def test_error_body_carries_both_keys() -> None: + """`error` for the App bridge, `detail` for the global toast. + + A string-valued `error` alone matches none of ErrorService's four + lookups, so the toast fell back to "The request conflicts with the + current state." while the real message sat unread in the body. + """ + assert app_tool_error_body("connect the account") == { + "error": "connect the account", + "detail": "connect the account", + } + + +def test_error_body_detail_is_what_the_toast_reads() -> None: + # ErrorService priority 1 is a top-level string `detail`. + body = app_tool_error_body("Authorization required for 'google-tasks'.") + assert isinstance(body.get("detail"), str) + assert body["detail"] == "Authorization required for 'google-tasks'." diff --git a/backend/tests/shared/test_search_filtering.py b/backend/tests/shared/test_search_filtering.py index a993788de..54e10abf3 100644 --- a/backend/tests/shared/test_search_filtering.py +++ b/backend/tests/shared/test_search_filtering.py @@ -244,3 +244,36 @@ def test_filter_empty_vectors(mock_boto3_resource): assert result == [] # boto3.resource should not be called for empty input mock_boto3_resource.assert_not_called() + + +# ----------------------------------------------------------------------- +# managed-kb-migration §5.33 / task 16.3: chunks present but NONE carry a +# document_id → fail closed. This was the one fail-OPEN line left in an +# otherwise fail-closed function (`if not doc_ids: return vectors`): it served +# chunks whose parent document could never be confirmed `complete` — including +# deleted content — whenever `_document_id` resolved to "" for the whole batch. +# Reverting the fix (back to `return vectors`) makes this test fail. +# ----------------------------------------------------------------------- + + +@patch("apis.shared.assistants.rag_service.emit_count") +@patch("boto3.resource") +@patch.dict("os.environ", ENV_PATCH) +def test_filter_fails_closed_when_no_chunk_carries_a_document_id(mock_boto3_resource, mock_emit): + """Non-empty batch where no chunk has a document_id — drop them all.""" + from apis.shared.assistants.rag_service import _filter_vectors_by_document_status + + # One chunk missing the key entirely, one with an empty id (the exact input + # `_document_id` produces when location + both metadata mirrors are absent). + vectors = [ + {"key": "k0", "distance": 0.5, "metadata": {"text": "orphan chunk 0"}}, + {"key": "k1", "distance": 0.6, "metadata": {"document_id": "", "text": "orphan chunk 1"}}, + ] + + result = _filter_vectors_by_document_status(vectors, ASSISTANT_ID) + + assert result == [], "chunks with no confirmable document_id must not leak" + # The degradation is reported, distinguishing this from an ordinary "no match". + mock_emit.assert_called_once() + # Nothing to look up, so DynamoDB is never contacted. + mock_boto3_resource.assert_not_called() diff --git a/backend/tests/supply_chain/test_kb_migration_env_contract.py b/backend/tests/supply_chain/test_kb_migration_env_contract.py index e535343bc..99fb46b4d 100644 --- a/backend/tests/supply_chain/test_kb_migration_env_contract.py +++ b/backend/tests/supply_chain/test_kb_migration_env_contract.py @@ -194,6 +194,7 @@ def test_no_kb_migration_variable_is_published_unread(self): [ "MANAGED_KB_MIGRATION_ENABLED", "MANAGED_KB_RECONCILER_ARMED", + "MANAGED_KB_DOC_RECONCILER_ARMED", "MANAGED_KB_SERVICE_ROLE_ARN", "S3_ASSISTANTS_DOCUMENTS_BUCKET_NAME", ], diff --git a/backend/tests/supply_chain/test_lambda_image_imports.py b/backend/tests/supply_chain/test_lambda_image_imports.py index 30a61e7a7..ee564b4b9 100644 --- a/backend/tests/supply_chain/test_lambda_image_imports.py +++ b/backend/tests/supply_chain/test_lambda_image_imports.py @@ -69,6 +69,7 @@ "apis/app_api/kb_migration/dispatcher.py", "apis/app_api/kb_migration/worker.py", "apis/app_api/kb_migration/reconciler.py", + "apis/app_api/kb_migration/document_reconciler.py", "apis/app_api/kb_migration/ingestion_consumer.py", ], [], diff --git a/backend/tests/test_seed_system_admin_jwt.py b/backend/tests/test_seed_system_admin_jwt.py index 41f709392..c40d928b9 100644 --- a/backend/tests/test_seed_system_admin_jwt.py +++ b/backend/tests/test_seed_system_admin_jwt.py @@ -13,6 +13,7 @@ ) from seed_bootstrap_data import ( # noqa: E402 + DEFAULT_TOOLS, EXAMPLE_SKILL_ID, seed_default_role, seed_example_skills, @@ -131,7 +132,7 @@ def test_creates_default_tools(self, dynamodb_table): """Creates the default tool entries.""" result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 9 + assert result.created == len(DEFAULT_TOOLS) assert result.failed == 0 # Verify fetch_url_content @@ -230,6 +231,21 @@ def test_creates_default_tools(self, dynamodb_table): assert item["GSI1PK"] == "CATEGORY#document" assert item["GSI1SK"] == "TOOL#workspace_files" + # Verify browse_web. enabledByDefault MUST stay False: each session + # bills an AgentCore Browser session on top of model tokens, so this + # is opt-in per user and granted per role. + resp = dynamodb_table.get_item( + Key={"PK": "TOOL#browse_web", "SK": "METADATA"} + ) + item = resp["Item"] + assert item["toolId"] == "browse_web" + assert item["displayName"] == "Web Browser" + assert item["category"] == "browser" + assert item["protocol"] == "local" + assert item["enabledByDefault"] is False + assert item["GSI1PK"] == "CATEGORY#browser" + assert item["GSI1SK"] == "TOOL#browse_web" + # Verify create_excel_spreadsheet (single toggle for the whole Excel toolset) resp = dynamodb_table.get_item( Key={"PK": "TOOL#create_excel_spreadsheet", "SK": "METADATA"} @@ -264,7 +280,7 @@ def test_skips_existing_tools(self, dynamodb_table): result = seed_default_tools(TABLE_NAME, REGION) - assert result.skipped == 9 + assert result.skipped == len(DEFAULT_TOOLS) assert result.created == 0 def test_partial_skip(self, dynamodb_table): @@ -278,7 +294,7 @@ def test_partial_skip(self, dynamodb_table): result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 8 + assert result.created == len(DEFAULT_TOOLS) - 1 assert result.skipped == 1 diff --git a/backend/uv.lock b/backend/uv.lock index 5f2c7f6bc..a0afe6749 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.19.1" +version = "1.20.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -123,9 +123,9 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, { name = "soupsieve", specifier = "==2.8.4" }, { name = "starlette", specifier = "==1.3.1" }, - { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.51.0" }, - { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.51.0" }, - { name = "strands-agents-tools", marker = "extra == 'agentcore'", specifier = "==0.8.6" }, + { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.55.0" }, + { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.55.0" }, + { name = "strands-agents-tools", marker = "extra == 'agentcore'", specifier = "==0.8.8" }, { name = "tiktoken", marker = "extra == 'dev'", specifier = "==0.12.0" }, { name = "trafilatura", specifier = "==2.0.0" }, { name = "types-aiofiles", marker = "extra == 'dev'", specifier = "==25.1.0.20260409" }, @@ -478,25 +478,30 @@ wheels = [ [[package]] name = "aws-sdk-bedrock-runtime" -version = "0.5.0" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smithy-aws-core", extra = ["eventstream", "json"], marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, - { name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, + { name = "smithy-http", extra = ["aiohttp"], marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/ff/c5396e82ab39a30cebd08c875250e72c1116f57c8c1f3e0dda1c1db9b177/aws_sdk_bedrock_runtime-0.5.0.tar.gz", hash = "sha256:cc0af7330bda9e398ad9ebf39c021a59dbc28c616a6c9c7765c355773b2402f4", size = 159805, upload-time = "2026-04-07T19:24:24.564Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/b3/9c225cbfe9f17ea2e3d75a0fdd0b325ef79839b9c09a376bda63a7bf3bb3/aws_sdk_bedrock_runtime-0.11.0.tar.gz", hash = "sha256:f2c45d34625bf6a7b56375e29a53a16b376880bda771e4bbf7d84491622eb193", size = 173854, upload-time = "2026-08-24T21:17:16.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/1c/b6f1b10435beee0c6289e2ce5e736bbd30555ce9b5898bd9f98f982827ad/aws_sdk_bedrock_runtime-0.5.0-py3-none-any.whl", hash = "sha256:87a002ba4d070e32e4343858c3aacc2cf040beed7f03c93e2b984e78a4f4ceb6", size = 84911, upload-time = "2026-04-07T19:24:25.49Z" }, + { url = "https://files.pythonhosted.org/packages/29/0c/9512304ed017ce49992df6661eac2b914550247e13bccb55be6ca594170d/aws_sdk_bedrock_runtime-0.11.0-py3-none-any.whl", hash = "sha256:ef01c26ddfd83a5d3e438ab72ebb3c13b41fc0ef11d81095b22c8016f97e9795", size = 97112, upload-time = "2026-08-24T21:17:17.396Z" }, +] + +[package.optional-dependencies] +awscrt = [ + { name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, ] [[package]] name = "aws-sdk-signers" -version = "0.2.0" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/1e/e93f5b7711bb9c3b97a4d5486ee3a74db412a66bf6160977bb69f0434830/aws_sdk_signers-0.2.0.tar.gz", hash = "sha256:3b23fb02ababb9e768fc102ab7584e344be8d65324ef3c33df4b8cad6cee26e7", size = 18069, upload-time = "2026-04-07T19:24:09.72Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/ed/3bc0697ea9c4fed38d3742f373e2a3bb77937f9e1f9b6ef08309e326f195/aws_sdk_signers-0.3.1.tar.gz", hash = "sha256:1f305b753968c98f1cd4bdc498f52a1b7dbedc8d7a07cb3631170de2e572e9a9", size = 18675, upload-time = "2026-08-24T21:16:54.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/f3/cb2cd5177bb6c21d339b96079ece8e30ffe9954540494bae73a6498677ac/aws_sdk_signers-0.2.0-py3-none-any.whl", hash = "sha256:e770dcc390e18093840ef4ce1ac70fc419fe6ac8737809ffce9a9fab56d8ac2d", size = 21621, upload-time = "2026-04-07T19:24:08.57Z" }, + { url = "https://files.pythonhosted.org/packages/c8/51/0a3e4314715f80956ea73e3b02b7d3bccdbc9d96950000ed21cbea5ef15a/aws_sdk_signers-0.3.1-py3-none-any.whl", hash = "sha256:a2d26c085f36e8069a8b499a55ac4dff68c8d2cceafe31797c977fd2c5c73579", size = 21934, upload-time = "2026-08-24T21:16:55.512Z" }, ] [[package]] @@ -4560,16 +4565,16 @@ wheels = [ [[package]] name = "smithy-aws-core" -version = "0.5.0" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aws-sdk-signers", marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, { name = "smithy-http", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/a5/abc799cb5263136c94838b8acc987b4c423c08a048ba63d5ebb934b5dde9/smithy_aws_core-0.5.0.tar.gz", hash = "sha256:8f6c758f986657de6e4e182d28cfd5761789478f1b12275b5686789728488f08", size = 15475, upload-time = "2026-04-07T19:24:19.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/d3/501c0023548173416109ac42298ca33b708469dc922005770811a597949f/smithy_aws_core-0.11.0.tar.gz", hash = "sha256:29ee89976a520a87e3db557e03e115fdc21a0a60b81161e95174395a1b064da1", size = 38791, upload-time = "2026-08-24T21:16:59.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/3f/878740067fb1ee8f5d551eed016b69dafa9a8eb3c968a637986cdd528a41/smithy_aws_core-0.5.0-py3-none-any.whl", hash = "sha256:08a4d2b5b71fb09f06f43405c82c4c5b466011c3dde1b4ae578e240a383473a0", size = 24802, upload-time = "2026-04-07T19:24:18.596Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f6/fefda9aab809fa1a62bf7073bd6d8ab427bd9989f39b13c0d6e29d4d1045/smithy_aws_core-0.11.0-py3-none-any.whl", hash = "sha256:77cf130c22deac14a8cbeb8ccc4bcfe5a91798f4b38cb53a987080ec58c89f23", size = 58855, upload-time = "2026-08-24T21:16:58.657Z" }, ] [package.optional-dependencies] @@ -4582,53 +4587,57 @@ json = [ [[package]] name = "smithy-aws-event-stream" -version = "0.2.1" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/3f/c1544c3336122571b371eea2dd0dc2542112f172029f06bb43e4c09c128e/smithy_aws_event_stream-0.2.1.tar.gz", hash = "sha256:ae67ea3e582f2c2c556e302e57bc90a19b9b5847bcc8520e89396bcafc26235f", size = 12334, upload-time = "2025-12-30T23:23:27.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/0e/6efb3a4ed92c0f1ada6de060ac92e7115a1e34d0ab1fb99a6056734a88ea/smithy_aws_event_stream-0.3.0.tar.gz", hash = "sha256:a0e227367a973144e205a075d0a424f95c92f26656a1018d08900da2ae547c49", size = 12818, upload-time = "2026-05-05T18:04:14.317Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/80/b52689e3dc39d478f7ba69ca18be2e762b3866efe4ae5312d69059f9fe01/smithy_aws_event_stream-0.2.1-py3-none-any.whl", hash = "sha256:31d1089306732b4f35e1c6be2e8c2a3f7524c72c59aa2fd1dcba77b97bef4bc3", size = 15569, upload-time = "2025-12-30T23:23:26.411Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b4/c4c4a9aa5a4cf3ee285f1bd71999441177c651d911a82a6e64c57b6e3e65/smithy_aws_event_stream-0.3.0-py3-none-any.whl", hash = "sha256:8b505cc28230e4fe9c5e025333209b44ca2db560451ac9ed9a7d74939edf4413", size = 15845, upload-time = "2026-05-05T18:04:13.351Z" }, ] [[package]] name = "smithy-core" -version = "0.4.0" +version = "0.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/9c/d4eb7fb59f377b712477a468132ad483179c7877064a16110d6f2109c309/smithy_core-0.4.0.tar.gz", hash = "sha256:eee8bfdcb1f1453c1f20d8b6214fac579b57a7c585ee2f503c404c93d274bb9a", size = 50880, upload-time = "2026-04-07T19:24:11.851Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/c6/93e9eea3c6163228dfe972c3e989e0553047858805ab7aa4a59f074ba129/smithy_core-0.8.1.tar.gz", hash = "sha256:3d2f8fca5960d74bd7ef380f70901c7bcdebe53f929d2d3d2fa6cb790b3f5214", size = 54259, upload-time = "2026-08-20T17:55:30.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/25/56488440a801f462db96500ef3d543bf860c4319e77c36d09900a8c27587/smithy_core-0.4.0-py3-none-any.whl", hash = "sha256:50b99e7debecadc6a29a483717ac707ac0480d948f87793b73c26a6b37b03cc5", size = 65010, upload-time = "2026-04-07T19:24:10.874Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/c6430bbf406477fc7d16254b9908a723b299a4a21a94c99db9d12c84a8bf/smithy_core-0.8.1-py3-none-any.whl", hash = "sha256:44bd9bdf702f76919af58e44a6a1bb3dc136a745b2f955281743022ce767e347", size = 68805, upload-time = "2026-08-20T17:55:29.366Z" }, ] [[package]] name = "smithy-http" -version = "0.4.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/b4/7422f8c06ab8bd70e6b04e8cd570fb16bc5068bc21066f318bee2b8e8e29/smithy_http-0.4.0.tar.gz", hash = "sha256:212b233d55f9006cbf4b8df7a42e17b841d4cfbe9b25d9afe2ad903e364fc79c", size = 29021, upload-time = "2026-04-07T19:24:14.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/78/b5f3113d6c8f0bc1f9777a7f5ca84b892d29efac05850e14f7d4f7e645b5/smithy_http-0.5.0.tar.gz", hash = "sha256:bb4a19672f7c7eeb872a308f777eb505281a5bafb1ee3d1ea9c760c06c352510", size = 31122, upload-time = "2026-08-24T21:16:56.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/cd/953eb19e4683f76dac97b9ac30656955d4420211dc1464f5de2faa065fe1/smithy_http-0.4.0-py3-none-any.whl", hash = "sha256:ae381d8bee199d1604ae1a983e59e0b8f51b3bf3561587be9381f4c62818ff72", size = 40536, upload-time = "2026-04-07T19:24:13.075Z" }, + { url = "https://files.pythonhosted.org/packages/27/27/e414082643028846b73afa52a1a8f934548196ee12b187a06803f02a3e66/smithy_http-0.5.0-py3-none-any.whl", hash = "sha256:af273d5f42e7733ce7a6e9bd6fdd6a59ef1b61f6cd1f4a89dd53dfce99da7bef", size = 42198, upload-time = "2026-08-24T21:16:57.52Z" }, ] [package.optional-dependencies] +aiohttp = [ + { name = "aiohttp", marker = "python_full_version >= '3.12'" }, + { name = "yarl", marker = "python_full_version >= '3.12'" }, +] awscrt = [ { name = "awscrt", marker = "python_full_version >= '3.12'" }, ] [[package]] name = "smithy-json" -version = "0.2.2" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ijson", marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/50/5ea5bdb003e2d89afa6dfa8702208f2e61db56b4261656435aa15e176f55/smithy_json-0.2.2.tar.gz", hash = "sha256:e40f154455ebceb0552ef37310c5d0c297c6e6690087396adacf06fb170f59de", size = 7644, upload-time = "2026-04-07T19:24:15.136Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/ac/04164eefb3da7479f52f6535b4b39cc8384c292cb2bb74279f2acc4f4b4d/smithy_json-0.3.0.tar.gz", hash = "sha256:c81c7034587e01bc64767cbbecb05a7d65ca9070612fd94e8a03e80540290a22", size = 7956, upload-time = "2026-08-20T17:55:32.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/bd/761ae4148699ec2a20ff975379aa439a6123420d102bc23a356dd64667d9/smithy_json-0.2.2-py3-none-any.whl", hash = "sha256:4c7be8532f4ae0429a8a6676adf41e40941893ce7d491bc7faa50590077f97da", size = 10177, upload-time = "2026-04-07T19:24:15.846Z" }, + { url = "https://files.pythonhosted.org/packages/9d/cf/0104c40a0e18fa307ea3da4310eba949f474a5bc1df3cc2b5851a72e8486/smithy_json-0.3.0-py3-none-any.whl", hash = "sha256:ffb73d2e60cf5e616e5d0a1019e7b9f518edba076cb423f10981457725dcddc4", size = 10252, upload-time = "2026-08-20T17:55:31.204Z" }, ] [[package]] @@ -4685,7 +4694,7 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.51.0" +version = "1.55.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -4702,20 +4711,22 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/07/cb77bb1773d885e96dc0412920c620cdc660ef5113e9b61c3cd026240102/strands_agents-1.51.0.tar.gz", hash = "sha256:4cbd0b183aad0bb60ac7c6043d29841c8933937c97228e91669d2d97fc40776d", size = 1285092, upload-time = "2026-08-07T18:07:43.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/37/04f19549c24bd7dbdbf5010317ed9b090f454aae550ed188b8b030b1be50/strands_agents-1.55.0.tar.gz", hash = "sha256:8ad2ad0b5306e1e0419cde03534d3a725d8fb80aa19ed7065c844bb2ffbd7dc1", size = 1599495, upload-time = "2026-09-08T16:55:24.132Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/4c/937595709a56e966bc471d2fe14071910b2f57d1975a7fd701da18eedabe/strands_agents-1.51.0-py3-none-any.whl", hash = "sha256:1e0a8d125652d7a863cae8fd717b22e4fa5c7983b499438d1f0f50bf328e8832", size = 663778, upload-time = "2026-08-07T18:07:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/6db60946626fb7f83249ac9f8e3037947f3aeac9b93bbbf116a4edd7be6c/strands_agents-1.55.0-py3-none-any.whl", hash = "sha256:01cd3ebd0606042b0e0c099aa6e549080a945ed3f4e063c223bf34f11373cd75", size = 807217, upload-time = "2026-09-08T16:55:22.37Z" }, ] [package.optional-dependencies] bidi = [ - { name = "aws-sdk-bedrock-runtime", marker = "python_full_version >= '3.12'" }, + { name = "aws-sdk-bedrock-runtime", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, + { name = "prompt-toolkit" }, { name = "smithy-aws-core", marker = "python_full_version >= '3.12'" }, + { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] [[package]] name = "strands-agents-tools" -version = "0.8.6" +version = "0.8.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -4736,9 +4747,9 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/fc/63031f0d5483a036eb732ae52624e6429ed1385d9c68515763c323da00fd/strands_agents_tools-0.8.6.tar.gz", hash = "sha256:3cdafd706909be6e8cab5411067cdc5af269bec29f1903788629e3f092d9e8aa", size = 533539, upload-time = "2026-08-07T18:09:03.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e2/18f496ee1f3c17071228b76be67b50621d9f9061ee54c320bcb8b1683370/strands_agents_tools-0.8.8.tar.gz", hash = "sha256:7ec790d888ea5e24df038f40bcd52aa6ba82d86c578e5148f306c0969e5cbade", size = 539062, upload-time = "2026-09-04T14:58:21.668Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/32/36ff3b19528430d68f740bf61d6fe1f4afbf670f69a2ca204ae1f04139e2/strands_agents_tools-0.8.6-py3-none-any.whl", hash = "sha256:ed87ef1c405c635164e7e85e1cb0a1560bd30156b54b3c6cb11574e67d111d5c", size = 338989, upload-time = "2026-08-07T18:09:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4d/7680c2c620c7531eb063f2964804c763b580f1bfea5b948ef862b3031f29/strands_agents_tools-0.8.8-py3-none-any.whl", hash = "sha256:5e075462cdfc3cd96b31d5a3a552e24d5eacae42687e5378c1e5a469c7ef9d64", size = 339705, upload-time = "2026-09-04T14:58:19.783Z" }, ] [[package]] diff --git a/docs-site/src/content/docs/admin/fine-tuning.md b/docs-site/src/content/docs/admin/fine-tuning.md index 20244be10..6e8572cf3 100644 --- a/docs-site/src/content/docs/admin/fine-tuning.md +++ b/docs-site/src/content/docs/admin/fine-tuning.md @@ -20,10 +20,16 @@ Deep Learning Container the job runs in. | `text-classification` | text → label | `.csv` / `.jsonl` / `.json` | `text`, `label` | | `image-classification` | image → label | `.zip` | `image`, `label` | | `image-text-classification` | image + text → label | `.zip` | `image`, `text`, `label` | +| `image-text-to-text` | image + prompt → free text | `.zip` | `image`, `prompt`, `response` | -All three produce the same output: a softmax over the dataset's own classes, -written as a CSV with one probability column per class. That shared contract is -deliberate — it means a new modality never breaks the result viewer. +The three classification tasks produce the same output: a softmax over the +dataset's own classes, written as a CSV with one probability column per class. + +`image-text-to-text` is **generative** — it keeps the model's language head and +learns to write the response, so it has no label column and no class list. Its +result is still a CSV with one row per input record, carrying an `output` +column instead of probability columns, so the download and the result viewer +are unchanged. ### Image datasets @@ -68,6 +74,13 @@ Each task declares a DLC *family*, and the two families move independently: |---|---|---| | `text` | PyTorch 2.1 / transformers 4.36 | Every existing text model was trained and validated here. Bumping it would re-baseline all of them at once. | | `vision` | PyTorch 2.8 / transformers 4.56 | Modern vision checkpoints do not load on 4.36. | +| `vlm` | PyTorch 2.8 / transformers 4.56 | Same image as `vision`, but its own family so it can install `peft` and `bitsandbytes` — and so a future VLM-only image bump cannot re-baseline the image classifiers. | + +Each family gets its own `sourcedir-.tar.gz`, because the dependency +sets differ. `bitsandbytes` requires torch >= 2.4, which the text container +(torch 2.1) cannot satisfy, so a single shared `requirements.txt` would break +dependency installation for every existing text job. The file each family gets +is `script_packaging_service.REQUIREMENTS_BY_FAMILY`. If a tag is retired or lags in a region, override it without a code deploy: @@ -145,9 +158,32 @@ The commonest refusal is a **GGUF-only repository**. GGUF is a llama.cpp inference format and cannot be fine-tuned by transformers at all — look for the original, unquantised repository instead. -Note that generative vision-language models (`image-text-to-text`, e.g. LLaVA -or Qwen-VL) are **not** supported. They emit tokens rather than a class -distribution, and would need a separate generative task type with LoRA/PEFT. +Generative vision-language models (LLaVA, Qwen-VL) belong to the +`image-text-to-text` task, not to `image-text-classification` — the latter +needs a *dual encoder* exposing `get_image_features` and `get_text_features` +(the CLIP/SigLIP/ALIGN family), and a generative checkpoint has no text tower +to pool. + +### Generative VLMs are LoRA-adapted, not fully fine-tuned + +A full fine-tune needs roughly 16 bytes per parameter once gradients and the +AdamW moments are resident — about 550 GB for a 34B model, against the 384 GB +on the largest instance offered. So `image-text-to-text` quantises the frozen +base to 4-bit (NF4) and trains low-rank adapters on top. Two consequences worth +knowing: + +- **The artifact is an adapter, not a model.** It is a few hundred MB rather + than tens of GB, and inference reloads the base from the Hub and applies the + adapter over it. `vlm_adapter.json` inside the artifact records which base + and whether it was quantised. +- **Loss is computed on the response only.** The prompt is masked out, so the + model does not spend its gradient budget learning to reproduce questions it + will always be given. + +`load_in_4bit`, `lora_r` and `lora_alpha` are exposed on the create-job form +for this task. Models below about 3B are faster in bfloat16 — the 4-bit path +pays a dequantisation cost that only earns its keep when the model would not +otherwise fit. ## Admin surface diff --git a/docs/kaizen/review-queue.md b/docs/kaizen/review-queue.md index efaf22c89..5fe8a8fbf 100644 --- a/docs/kaizen/review-queue.md +++ b/docs/kaizen/review-queue.md @@ -7,6 +7,40 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. > ✅ **Queue hygiene completed 2026-08-14** (at Phil's request, ahead of `kaizen-review-prep`). **Nine** stale entries were resolved: four `bedrock-agentcore` bump entries and two Strands bump entries (all **shipped** in #857 — `bedrock-agentcore` 1.9.1 → **1.21.0** at zero lag, `strands-agents` → **1.51.0**; #482 and #571 closed upstream), two nightly-CI entries (**green 12 consecutive days**), and two MCP Apps spec-prep entries (superseded now the 2026-07-28 spec is final). Genuine residue was carried forward, not dropped: **#564** (still open upstream) and the **un-adopted Strands capabilities** are now their own entries below. See `## Resolved` for the evidence trail. +### [2026-09-08] AgentCore Runtime workspaces — give the agent a filesystem; start by mounting the one we already have +- **Source**: conversation with Phil, 2026-09-08 — **not** from a research scan. Verified against the pinned `botocore` 1.43.68 service model, the AgentCore devguide, and https://aws.amazon.com/blogs/machine-learning/its-safe-to-close-your-laptop-now-hosting-coding-agents-on-amazon-bedrock-agentcore/. +- **Surface**: infrastructure — `CreateAgentRuntime`'s `filesystemConfigurations` (nowhere in `infrastructure/lib` today; ✅ verified zero hits). Downstream: `apis/shared/files/workspace.py` and `agents/builtin_tools/workspace_tools.py` if the two surfaces converge. +- **Effort × Impact**: L–M × M for the S3-mount bridge; M–H × H for `sessionStorage` behind the gates below. +- **Subtracts**: **no — and that is not the point.** Filed under `Unlocks`. ⚠️ This is the same feature Phil corrected the framing on **2026-05-10** — AgentCore BYO filesystem was written up then as "replaces future filesystem-staging glue," which is what prompted the dual-lens rule now in both kaizen skills. It was framed subtraction-first *again* on 2026-09-08 and corrected again. Third time, rank it on `Unlocks` or don't file it. +- **Unlocks** — the whole case. Any **one** of the first three justifies building; the fourth is available now and waits on none of them: + - **Multi-turn work over an uploaded file.** A CSV uploaded once, then five follow-ups; today every turn re-fetches and re-parses. Directly on the cost thesis — attachment conversations are **11% of sessions and 31% of prod spend** ([[project_document_conversations_cost]]) — and a workspace is the only place a parsed intermediate could live between turns. + - **Artifacts that need a build step.** `artifact-render` is a single-shot Lambda; multi-file bundles, generated assets, a real dependency tree have nowhere to exist. + - **Long-running scheduled runs.** The best fit in the stack: session identity is already the resume key and no interactive user pays the remount latency. The moment a scheduled prompt is a multi-hour agentic task rather than one turn, suspend/resume stops being a nicety. + - **The SNR307 "Model C" thesis, if it ever grows a coding-agent-shaped product.** `sessionStorage` + `InvokeAgentRuntimeCommandShell` *is* that substrate; hand-rolling it would be indefensible. + - **Skill assets** — the brand deck builder already fights this (`template_name` is session-scoped, the template cannot persist as a file). +- **What the API actually is** (✅ verified locally against pinned botocore, not from docs prose). `CreateAgentRuntime` takes `filesystemConfigurations`, a list of four mount flavors: `sessionStorage {mountPath}` (per-session, private), `s3FilesAccessPoint {accessPointArn, mountPath}`, `efsAccessPoint {accessPointArn, mountPath}`, `capacityProviderVolume {mountPath, volumeName}`. Sibling `lifecycleConfiguration {idleRuntimeSessionTimeout, maxLifetime}` is the knob we already know from the idle-reaper work (#827). Compute stops at idle timeout; the filesystem stays and remounts on the same session id. Shell access is separate: **`InvokeAgentRuntimeCommand` is already in our pinned botocore** (one-shot, HTTP/2, 1–3,600s timeout, structured exit code); `InvokeAgentRuntimeCommandShell` is **not** a botocore op — it lives in the `bedrock_agentcore` 1.21.0 SDK at `runtime/shell/` (WebSocket, k8s-style channel framing, 1h max, reconnect by `shellId`). +- **Status**: open. Recommended: **Ship the bridge, Defer `sessionStorage` behind gates.** + - **The Ship candidate is #4 above** — mount `s3FilesAccessPoint` over the **existing** `user-files/{u}/{s}/…` layout. The agent gets POSIX reads of files it already owns; no new namespace, no second source of truth, DynamoDB stays authoritative, and the metadata-table-first rule that `workspace_files` was built on ([[project_session_workspace_tools_spec]]) survives intact. Infra-only change, no new API surface. + - **Gates on `sessionStorage`, two of them unresolved.** ⚠️ **Deletion path**: a persistent filesystem holding user files is invisible to our takedown/delete machinery, which is DynamoDB+S3-aware — if a user deletes a conversation, *what deletes the workspace?* Governance blocker, not a detail. ⚠️ **Durability is single-source**: `/mnt/workspace` and 14-day-inactivity retention come from the AWS blog only; the curated devguide index has **no** filesystem-persistence page, consistent with preview. Don't design against those numbers. Also needed: cost per idle conversation (we pin runtime session ids, so conversation ≈ runtime session — multiply before, not after), and a decided convergence story with `workspace_files` (coexist or merge, settled up front, or we recreate the `allowedAppRoles` failure mode where two surfaces disagree). + - ⚠️ **Naming collision, three ways.** "Workspace" means (a) this — an AgentCore Runtime mount; (b) our `workspace_files` tools, which are DynamoDB-backed and deliberately *not* a filesystem; (c) nothing at all in Strands. ✅ Verified there is **no** workspaces concept in Strands: zero refs in the installed `strands-agents==1.51.0`, zero of 2,482 files in `strands-agents/harness-sdk`, no page in the docs repo (the only hits are an Nx monorepo and a suggested evals folder name). + +### [2026-09-08] Strands Snapshots — the missing primitive for branch/regenerate, and a candidate answer to the agent-cache state rule +- **Source**: conversation with Phil, 2026-09-08 — **not** from a research scan. https://strandsagents.com/docs/user-guide/concepts/agents/snapshots/, read against our session-persistence code the same day. +- **Surface**: a new SPA affordance + `apis/inference_api/chat/service.py` (`_adopt_session_conversation`) for the redesign. Explicitly **not** `apis/shared/sessions/models.py:88` (`PausedTurnSnapshot`) or the compaction path — see the audit below. +- **Effort × Impact**: L × M for a branch/regenerate spike; M–H × M for the `_adopt_session_conversation` redesign. +- **Subtracts**: **no.** Filed under `Unlocks` — the audit found nothing to retire, and that negative result is recorded below so it is not re-run, but it is **not** the reason to rank this entry. +- **Unlocks**: + - **Branch / regenerate / edit-and-resend — a product capability we do not have at all.** ✅ Verified: no regenerate, no edit-and-resend, no conversation fork anywhere in the SPA. `take_snapshot` before a turn + `load_snapshot` to rewind is exactly that primitive, and it is the standard affordance every comparable chat product ships. This is the entry's actual case. + - **Checkpoint-and-rollback for multi-step agentic work** — save at a milestone, roll back on a bad result. The natural pairing with long-running scheduled runs, and with the AgentCore workspace entry above. + - **A structural answer to the `CLAUDE.md` rule "never cache session state on an agent instance."** `_adopt_session_conversation` aliases message lists across cached instances because `initialize()` never re-runs on a cache hit — the #741 / #751 shape, which has now bitten twice. A session-scoped snapshot loaded at the head of *every* turn, hit or miss, is the cleaner form of that. +- **Already available — no bump needed.** ✅ In our pinned `strands-agents==1.51.0`: `Agent.take_snapshot` / `load_snapshot` at `strands/agent/agent.py:1543`, types in `strands/types/_snapshot.py`. Preset `"session"` captures `messages`, `state`, `conversation_manager_state`, `interrupt_state`, `model_state`; `system_prompt` is opt-in via `include`. Storage is ours; `app_data` is an arbitrary JSON bag Strands never reads. Positioned upstream as the **manual** counterpart to SessionManager. +- **The subtraction audit, for the record — four candidates, zero replaceable.** Recorded so this is not re-investigated; the name collides with two things we already ship. + - **`PausedTurnSnapshot`** (`models.py:88`, written by `_persist_paused_turn_snapshot` at `stream_coordinator.py:1616`) — same word, disjoint content. We persist *construction params* (`enabled_tools`, `model_id`, `agent_type`, `enabled_skills`, `mantle_api_mode`, `inference_params`); none is a Strands snapshot field, and `model_state` is provider-internal, not our `ModelConfig`. ✅ Verified the runtime half is already handled: `AgentCoreMemorySessionManager` subclasses `RepositorySessionManager`, whose `sync_agent` persists `interrupt_state` (plus `state` / `conversation_manager_state` / `model_state`) through `SessionAgent` (`strands/session/repository_session_manager.py:113–164`) — so the comment at `routes.py:2222` is **accurate**. + - **Compaction state** — `conversation_manager_state` looks analogous, but ✅ we configure **no** Strands `ConversationManager` at all (zero grep hits across `agents/` + `apis/`); compaction lives in `TurnBasedSessionManager.initialize()`. + - **`PreviewSessionManager`** (205 lines re-implementing `SessionMessage` wrapping and message indexing) — the only plausible deletion, ~30 lines. But the agent cache already supplies preview's multi-turn continuity and a snapshot version would not survive a container hop either. Not recommended. + - **Agent version snapshots** (marketplace, #783–#801) — same word, different object: those version agent *configuration* for publish/rollback, not runtime state. +- **Status**: open. Recommended: **Ship the branch/regenerate spike, Defer the redesign.** ⚠️ **Prompt-cache note, in the feature's favour:** `load_snapshot` restores messages by `copy.deepcopy`, so a round-trip is byte-stable *by construction* — arguably safer than re-deriving history from AgentCore Memory through the sanitizers and pairing repair, which is the divergence `_adopt_session_conversation`'s docstring already warns about. ⚠️ **Counter-gate:** that docstring also says the stale path is a cache **hit**, where nothing runs; a snapshot design must rebind `agent.messages` mid-life on every turn, which it names as what would silently break the alias — `test_second_cache_key_for_a_session_shares_the_conversation` has to be re-reasoned, not just kept green. ⚠️ Making a snapshot the store of record over AgentCore Memory was considered and **rejected**: it breaks LTM extraction wholesale. ⚠️ Upstream limits: `"session"` is the only preset, no schema migration off `"1.0"`, and messages restore verbatim — an untrusted snapshot reaches the model. + ### [2026-09-05] ✅ RESOLVED — PROD `gpt-5.4` cache rate set - **Source**: live measurement in dev, then verified and fixed in prod the same day. - **Status**: **done.** Prod `openai.gpt-5.4` now carries `supportsCaching: true`, `cacheReadPricePerMillionTokens: 0.275` (0.1x its $2.75 input, the ratio confirmed across the GPT family in the Price List) and `cacheWritePricePerMillionTokens: 0` (that model has a cache-read SKU and **no** cache-write SKU). Verified on the record at 2026-09-05T17:41Z; name, prices and `enabled` untouched. diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index c8a2b8b83..980829165 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.19.1", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.19.1", + "version": "1.20.0", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.19", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 276bb9890..dbb48b818 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.19.1", + "version": "1.20.0", "scripts": { "ng": "ng", "prestart": "tsx scripts/branding/generate-brand-theme.ts && tsx scripts/branding/generate-surface-theme.ts && tsx scripts/branding/generate-surface-colors.ts && tsx scripts/branding/generate-favicons.ts", diff --git a/frontend/ai.client/src/app/fine-tuning/models/fine-tuning.models.ts b/frontend/ai.client/src/app/fine-tuning/models/fine-tuning.models.ts index d38969c8e..db287d854 100644 --- a/frontend/ai.client/src/app/fine-tuning/models/fine-tuning.models.ts +++ b/frontend/ai.client/src/app/fine-tuning/models/fine-tuning.models.ts @@ -22,7 +22,8 @@ export interface FineTuningAccessResponse { export type FineTuningTaskType = | 'text-classification' | 'image-classification' - | 'image-text-classification'; + | 'image-text-classification' + | 'image-text-to-text'; export const DEFAULT_TASK_TYPE: FineTuningTaskType = 'text-classification'; @@ -35,6 +36,11 @@ export interface TaskTypeResponse { requires_archive: boolean; inference_upload_extensions: string[]; default_instance_type: string; + /** True when the model emits free text rather than class probabilities. + * Generative tasks are LoRA-adapted, so they expose adapter controls and + * their result file carries an output column instead of one probability + * column per class. */ + is_generative: boolean; } // ── Model Catalog ─────────────────────────────────────────────────────── diff --git a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html index dd8647936..82e50ec33 100644 --- a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html +++ b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html @@ -464,8 +464,9 @@

4. Trai } - - @if (requiresArchive()) { + + @if (showImageSize()) {
4. Trai
} + + @if (isGenerative()) { +
+ + +

Higher adapts more, and costs more memory.

+
+ +
+ + +

Conventionally twice the rank.

+
+ +
+ + +
+ } +
diff --git a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.spec.ts b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.spec.ts index 4ea0e5c9f..38fa27b12 100644 --- a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.spec.ts +++ b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.spec.ts @@ -9,6 +9,7 @@ import { FineTuningHttpService } from '../../services/fine-tuning-http.service'; import { FineTuningUploadService } from '../../services/fine-tuning-upload.service'; import type { AvailableModel, + FineTuningTaskType, JobResponse, PresignResponse, TaskTypeResponse, @@ -84,6 +85,7 @@ const mockTaskTypes: TaskTypeResponse[] = [ requires_archive: false, inference_upload_extensions: ['.txt', '.csv', '.jsonl', '.json'], default_instance_type: 'ml.g5.xlarge', + is_generative: false, }, { task_type: 'image-classification', @@ -94,6 +96,18 @@ const mockTaskTypes: TaskTypeResponse[] = [ requires_archive: true, inference_upload_extensions: ['.zip'], default_instance_type: 'ml.g6.xlarge', + is_generative: false, + }, + { + task_type: 'image-text-to-text', + display_name: 'Image + text to text', + description: 'Teach a vision-language model to answer about an image.', + required_columns: ['image', 'prompt', 'response'], + upload_extensions: ['.zip'], + requires_archive: true, + inference_upload_extensions: ['.zip'], + default_instance_type: 'ml.g6e.xlarge', + is_generative: true, }, ]; @@ -330,6 +344,105 @@ describe('CreateTrainingJobPage', () => { ); }); + describe('generative (image-text-to-text) tasks', () => { + const vlmModel: AvailableModel = { + model_id: 'llava-1.5-7b', + model_name: 'LLaVA 1.5 7B', + huggingface_model_id: 'llava-hf/llava-1.5-7b-hf', + description: 'Vision-language model', + task_type: 'image-text-to-text', + default_instance_type: 'ml.g6e.xlarge', + default_hyperparameters: { + epochs: '3', + learning_rate: '1e-4', + per_device_train_batch_size: '1', + context_length: '1024', + lora_r: '8', + lora_alpha: '16', + load_in_4bit: 'false', + split_ratio: '0.8', + }, + }; + + /** The task list is fetched on a microtask, so the spec has to let + * loadTaskTypes() settle before a task can be selected by name. */ + async function selectTask(taskType: FineTuningTaskType) { + const component = createComponent(); + await Promise.resolve(); + component.selectTaskType(taskType); + return component; + } + + const selectVlmTask = () => selectTask('image-text-to-text'); + + it('reports the task as generative from the backend spec', async () => { + const component = await selectVlmTask(); + expect(component.isGenerative()).toBe(true); + }); + + it('hides the image-size knob the generative trainer ignores', async () => { + const component = await selectVlmTask(); + // Archive-based, so the classification gate alone would show it. + expect(component.requiresArchive()).toBe(true); + expect(component.showImageSize()).toBe(false); + }); + + it('still shows image size for an image classifier', async () => { + const component = await selectTask('image-classification'); + expect(component.showImageSize()).toBe(true); + }); + + it('seeds the adapter controls from the model defaults', async () => { + const component = await selectVlmTask(); + component.selectModel(vlmModel); + const values = component.form.getRawValue(); + expect(values.loraR).toBe('8'); + expect(values.loraAlpha).toBe('16'); + expect(values.loadIn4bit).toBe('false'); + }); + + it('submits the adapter settings for a generative task', async () => { + const component = await selectVlmTask(); + const router = TestBed.inject(Router); + vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.selectModel(vlmModel); + component.uploadState.set({ + file: new File([''], 'data.zip'), + progress: 100, + status: 'complete', + s3Key: 'uploads/data.zip', + }); + + await component.submitJob(); + + const call = mockState.createTrainingJob.mock.calls[0][0]; + expect(call.hyperparameters).toEqual( + expect.objectContaining({ lora_r: '8', lora_alpha: '16', load_in_4bit: 'false' }), + ); + }); + + it('omits the adapter settings for a classification task', async () => { + const component = createComponent(); + const router = TestBed.inject(Router); + vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.selectModel(mockModel); + component.uploadState.set({ + file: new File([''], 'test.jsonl'), + progress: 100, + status: 'complete', + s3Key: 'uploads/test.jsonl', + }); + + await component.submitJob(); + + const call = mockState.createTrainingJob.mock.calls[0][0]; + expect(call.hyperparameters).not.toHaveProperty('lora_r'); + expect(call.hyperparameters).not.toHaveProperty('load_in_4bit'); + }); + }); + it('should convert max runtime hours to seconds', async () => { const component = createComponent(); const router = TestBed.inject(Router); diff --git a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.ts b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.ts index 84b15601f..02a8cb93c 100644 --- a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.ts +++ b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.ts @@ -93,6 +93,17 @@ export class CreateTrainingJobPage implements OnInit, OnDestroy { /** Whether the selected task expects a .zip bundling images with a manifest. */ readonly requiresArchive = computed(() => this.selectedTaskSpec()?.requires_archive ?? false); + /** Whether the selected task emits free text rather than class probabilities. + * Generative tasks are LoRA-adapted, so they expose adapter controls the + * classification tasks have no use for. */ + readonly isGenerative = computed(() => this.selectedTaskSpec()?.is_generative ?? false); + + /** Whether to show the image-resolution knob. + * The generative trainer never reads image_size — the model's own processor + * decides tiling and resolution — so offering the field there would be a + * control that silently does nothing. */ + readonly showImageSize = computed(() => this.requiresArchive() && !this.isGenerative()); + /** Columns a record must carry, for the upload hint. */ readonly requiredColumns = computed(() => this.selectedTaskSpec()?.required_columns ?? []); @@ -159,6 +170,9 @@ export class CreateTrainingJobPage implements OnInit, OnDestroy { seed: ['42'], contextLength: ['512'], imageSize: ['224'], + loraR: ['16'], + loraAlpha: ['32'], + loadIn4bit: ['true'], maxRuntimeHours: [24, [Validators.required, Validators.min(1), Validators.max(120)]], }); @@ -396,6 +410,9 @@ export class CreateTrainingJobPage implements OnInit, OnDestroy { seed: hp['seed'] ?? '42', contextLength: hp['context_length'] ?? '512', imageSize: hp['image_size'] ?? '224', + loraR: hp['lora_r'] ?? '16', + loraAlpha: hp['lora_alpha'] ?? '32', + loadIn4bit: hp['load_in_4bit'] ?? 'true', }); // Sync slider from model defaults (e.g. "0.8" → 80) @@ -443,6 +460,14 @@ export class CreateTrainingJobPage implements OnInit, OnDestroy { if (formValues.seed) hyperparameters['seed'] = formValues.seed; if (formValues.contextLength) hyperparameters['context_length'] = formValues.contextLength; if (formValues.imageSize) hyperparameters['image_size'] = formValues.imageSize; + // Adapter settings only mean something to a generative task. Sending + // them for a classifier would put dead keys in its job record, which is + // what the hyperparameters panel on the detail page renders. + if (this.isGenerative()) { + if (formValues.loraR) hyperparameters['lora_r'] = formValues.loraR; + if (formValues.loraAlpha) hyperparameters['lora_alpha'] = formValues.loraAlpha; + if (formValues.loadIn4bit) hyperparameters['load_in_4bit'] = formValues.loadIn4bit; + } // Convert slider percentage (e.g. 80) to decimal string (e.g. "0.8") hyperparameters['split_ratio'] = (this.splitSlider.value / 100).toString(); diff --git a/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.spec.ts b/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.spec.ts index dc37a7ea1..e5b8ad560 100644 --- a/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.spec.ts +++ b/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.spec.ts @@ -46,6 +46,7 @@ describe('KbUpgradeService', () => { expect(request.request.method).toBe('GET'); request.flush({ phase: 'available', + engine: 'classic', canUpgrade: true, progress: { completed: 0, total: 3, skipped: 1 }, reason: null, @@ -78,6 +79,28 @@ describe('KbUpgradeService', () => { expect(status.documentsNotCarried).toEqual([]); expect(status.noticePending).toBe(false); }); + + it('reads the engine when the server sends it', async () => { + // Drives the Managed/Classic badge (task 16.4). + const pending = service.getStatus(ENTITY); + http.expectOne(BASE).flush({ phase: 'succeeded', canUpgrade: false, engine: 'managed' }); + + expect((await pending).engine).toBe('managed'); + }); + + it('defaults engine to classic when the server omits it', async () => { + const pending = service.getStatus(ENTITY); + http.expectOne(BASE).flush({ phase: 'available', canUpgrade: true }); + + expect((await pending).engine).toBe('classic'); + }); + + it('defaults engine to classic when the request fails', async () => { + const pending = service.getStatus(ENTITY); + http.expectOne(BASE).flush('boom', { status: 500, statusText: 'Server Error' }); + + expect((await pending).engine).toBe('classic'); + }); }); describe('start and retry', () => { diff --git a/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.ts b/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.ts index 9edd46088..9b2612a14 100644 --- a/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.ts +++ b/frontend/ai.client/src/app/knowledge-base/kb-upgrade.service.ts @@ -27,6 +27,15 @@ export type DocumentIssueKind = | 'still_processing' | 'being_removed'; +/** + * The UI-facing engine name, for the `Managed`/`Classic` badge (task 16.4). + * + * Deliberately friendly words, not the backend's internal engine ids: the badge + * exists to answer "which engine is serving this?" for a human. `classic` is the + * default the server sends for a legacy or not-yet-migrated knowledge base. + */ +export type KbEngine = 'managed' | 'classic'; + export interface UpgradeProgress { completed: number; total: number; @@ -46,6 +55,8 @@ export interface DocumentNotCarried { export interface UpgradeStatus { phase: UpgradePhase; + /** Which engine currently serves this knowledge base — drives the badge. */ + engine: KbEngine; canUpgrade: boolean; progress: UpgradeProgress | null; reason: string | null; @@ -63,6 +74,7 @@ export interface UpgradeResult { /** What the client falls back to when the status call fails. */ const NOTHING_TO_SHOW: UpgradeStatus = { phase: 'none', + engine: 'classic', canUpgrade: false, progress: null, reason: null, diff --git a/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.html b/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.html index 1b1cd43e9..57660d71c 100644 --- a/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.html +++ b/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.html @@ -469,7 +469,33 @@

Web sour @if (uploadedDocuments().length > 0) {
-

Uploaded Documents

+
+

Uploaded Documents

+ @if (showEngineBadge()) { + + + {{ engineBadgeLabel() }} + + } +
    @for (doc of uploadedDocuments(); track doc.documentId) {
  • @@ -495,7 +521,7 @@

    Uploaded {{ formatBytes(doc.sizeBytes) }} Uploaded [class.text-gray-500]="doc.status !== 'complete' && doc.status !== 'failed' && doc.status !== 'uploading' && doc.status !== 'chunking' && doc.status !== 'embedding'" [class.dark:text-gray-400]="doc.status !== 'complete' && doc.status !== 'failed' && doc.status !== 'uploading' && doc.status !== 'chunking' && doc.status !== 'embedding'" > - {{ doc.status }} + {{ statusLabel(doc.status) }} @if (doc.chunkCount) { diff --git a/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.spec.ts b/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.spec.ts index d4cfd9d09..191082f61 100644 --- a/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.spec.ts +++ b/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.spec.ts @@ -5,6 +5,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { KnowledgeBaseSectionComponent } from './knowledge-base-section.component'; import { KbUpgradeService, UpgradeStatus, DocumentNotCarried } from './kb-upgrade.service'; +import { Document } from '../assistants/models/document.model'; import { ConfigService } from '../services/config.service'; import { ToastService } from '../services/toast/toast.service'; import { DocumentService } from '../assistants/services/document.service'; @@ -30,6 +31,7 @@ import { OAuthConsentService } from '../services/oauth-consent/oauth-consent.ser function status(overrides: Partial = {}): UpgradeStatus { return { phase: 'none', + engine: 'classic', canUpgrade: false, progress: null, reason: null, @@ -507,4 +509,86 @@ describe('KnowledgeBaseSectionComponent — upgrade card', () => { } }); }); + + describe('engine visibility (task 16.4)', () => { + /** A minimal complete Document row for the list. */ + function doc(overrides: Partial = {}): Document { + return { + documentId: 'doc-1', + assistantId: 'ast-1', + filename: 'notes.pdf', + contentType: 'application/pdf', + sizeBytes: 1234, + status: 'complete', + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', + ...overrides, + } as Document; + } + + describe('status vocabulary', () => { + it('reads managed documents as processing → ready, never chunking/embedding', async () => { + await render(status({ phase: 'succeeded', engine: 'managed' })); + const c = fixture.componentInstance; + expect(c.statusLabel('uploading')).toBe('Processing'); + expect(c.statusLabel('complete')).toBe('Ready'); + expect(c.statusLabel('failed')).toBe('Failed'); + // A mid-migration record might still carry a legacy word; it must not + // surface the legacy vocabulary on the managed path. + expect(c.statusLabel('chunking')).toBe('Processing'); + expect(c.statusLabel('embedding')).toBe('Processing'); + }); + + it('keeps the finer-grained words for a classic knowledge base', async () => { + await render(status({ phase: 'none', engine: 'classic' })); + const c = fixture.componentInstance; + expect(c.statusLabel('uploading')).toBe('Uploading'); + expect(c.statusLabel('chunking')).toBe('Chunking'); + expect(c.statusLabel('embedding')).toBe('Embedding'); + expect(c.statusLabel('complete')).toBe('Complete'); + expect(c.statusLabel('failed')).toBe('Failed'); + }); + + it('shows "Processing", not "Uploading", for a managed doc still indexing', async () => { + await render(status({ phase: 'succeeded', engine: 'managed' })); + fixture.componentInstance.uploadedDocuments.set([doc({ status: 'uploading' })]); + fixture.detectChanges(); + expect(text()).toContain('Processing'); + expect(text()).not.toContain('Uploading'); + }); + }); + + describe('the Managed/Classic badge', () => { + it('labels a managed knowledge base "Managed"', async () => { + await render(status({ phase: 'succeeded', engine: 'managed' })); + expect(fixture.componentInstance.isManagedEngine()).toBe(true); + expect(fixture.componentInstance.engineBadgeLabel()).toBe('Managed'); + }); + + it('labels a legacy knowledge base "Classic"', async () => { + await render(status({ phase: 'none', engine: 'classic' })); + expect(fixture.componentInstance.isManagedEngine()).toBe(false); + expect(fixture.componentInstance.engineBadgeLabel()).toBe('Classic'); + }); + + it('defaults to classic when no status has been read', () => { + expect(fixture.componentInstance.engineBadgeLabel()).toBe('Classic'); + }); + + it('renders the badge next to the document list when documents exist', async () => { + await render(status({ phase: 'succeeded', engine: 'managed' })); + fixture.componentInstance.uploadedDocuments.set([doc()]); + fixture.detectChanges(); + expect(fixture.componentInstance.showEngineBadge()).toBe(true); + expect(text()).toContain('Managed'); + }); + + it('hides the badge when the knowledge base has no documents', async () => { + await render(status({ phase: 'succeeded', engine: 'managed' })); + fixture.componentInstance.uploadedDocuments.set([]); + fixture.detectChanges(); + expect(fixture.componentInstance.showEngineBadge()).toBe(false); + }); + }); + }); }); diff --git a/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.ts b/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.ts index 691bb076c..1b098109a 100644 --- a/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.ts +++ b/frontend/ai.client/src/app/knowledge-base/knowledge-base-section.component.ts @@ -27,6 +27,7 @@ import { Dialog } from '@angular/cdk/dialog'; import { DocumentService, DocumentUploadError } from '../assistants/services/document.service'; import { Document, + DocumentStatus, PROCESSING_STATUSES, STALE_DOCUMENT_THRESHOLD_MS, } from '../assistants/models/document.model'; @@ -215,6 +216,73 @@ export class KnowledgeBaseSectionComponent implements OnDestroy { */ readonly upgradePhase = computed(() => this.upgradeStatus()?.phase ?? 'none'); + // ── Engine visibility (task 16.4, HANDOFF §6) ─────────────────────────── + // + // Two surfaces, one source: the server-derived engine on the upgrade status. + // The badge names the engine, and the per-document status vocabulary follows + // it — because a managed knowledge base no longer emits chunking/embedding + // (PR #900), so its documents must not be described with legacy words. + + /** The engine serving this knowledge base; `classic` until a status is read. */ + readonly kbEngine = computed(() => this.upgradeStatus()?.engine ?? 'classic'); + + /** True when this knowledge base is served by the managed backend. */ + readonly isManagedEngine = computed(() => this.kbEngine() === 'managed'); + + /** The `Managed`/`Classic` badge label. */ + readonly engineBadgeLabel = computed(() => (this.isManagedEngine() ? 'Managed' : 'Classic')); + + /** + * Whether to show the engine badge at all. + * + * Only for an existing knowledge base that actually has documents — a badge on + * an empty or not-yet-created section would label a store with nothing in it. + * Engine is not sensitive, so this is not permission-gated. + */ + readonly showEngineBadge = computed( + () => this.mode() === 'edit' && this.uploadedDocuments().length > 0, + ); + + /** + * The user-facing label for a document's processing status, in the vocabulary + * of the knowledge base's engine (task 16.4, HANDOFF §6). + * + * Managed knowledge bases no longer emit `chunking`/`embedding` (PR #900): the + * managed ingestion consumer writes only `uploading` then `complete`, so the + * whole indexing wait showed as the literal word "Uploading". Managed therefore + * reads `uploading → processing → ready` (+ `failed`); legacy assistants keep + * the finer-grained words they still emit. The word "vector" appears nowhere, + * per Requirement 23.6. + */ + statusLabel(docStatus: DocumentStatus): string { + if (this.isManagedEngine()) { + switch (docStatus) { + case 'complete': + return 'Ready'; + case 'failed': + return 'Failed'; + default: + // `uploading` — and any legacy word a mid-migration record might still + // carry — reads as the honest "still working on it" on the managed path. + return 'Processing'; + } + } + switch (docStatus) { + case 'uploading': + return 'Uploading'; + case 'chunking': + return 'Chunking'; + case 'embedding': + return 'Embedding'; + case 'complete': + return 'Complete'; + case 'failed': + return 'Failed'; + default: + return docStatus; + } + } + /** Requirement 23.2 — the opt-in card. Only ever for owners and editors. */ readonly showUpgradeOffer = computed( () => this.upgradePhase() === 'available' && (this.upgradeStatus()?.canUpgrade ?? false), diff --git a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts index 798c31e62..352182343 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts @@ -4,6 +4,7 @@ import { provideMarkdown, MarkdownService } from 'ngx-markdown'; import { AssistantMessageComponent } from './assistant-message.component'; import { Message, ContentBlock } from '../../../services/models/message.model'; import { McpAppStateService } from '../../../services/mcp-apps/mcp-app-state.service'; +import { ChatStateService } from '../../../services/chat/chat-state.service'; import { UiResourceEvent } from '../../../../shared/utils/stream-parser/stream-parser-types'; function makeMessage(content: ContentBlock[]): Message { @@ -53,6 +54,8 @@ function makePromotedVisualToolBlock(name: string): ContentBlock { }; } +const VIEWED_SESSION = 'sess-viewed'; + describe('AssistantMessageComponent', () => { let fixture: ComponentFixture; let component: AssistantMessageComponent; @@ -254,7 +257,9 @@ describe('AssistantMessageComponent', () => { toolUseId: 'tooluse_mcp_app_1', result: { status: 'success', content: [{ text: 'Diagram displayed!' }] }, }); - mcpAppState.recordLive(makeUiResource('tooluse_mcp_app_1')); + // Resources are held per conversation and read against the viewed one. + TestBed.inject(ChatStateService).setViewedSession(VIEWED_SESSION); + mcpAppState.recordLive(VIEWED_SESSION, makeUiResource('tooluse_mcp_app_1')); fixture.componentRef.setInput('message', makeMessage([tool])); fixture.detectChanges(); @@ -281,6 +286,8 @@ describe('AssistantMessageComponent', () => { result: { status: 'success', content: [{ text: 'Diagram displayed!' }] }, }); + TestBed.inject(ChatStateService).setViewedSession(VIEWED_SESSION); + // Initial render: ui_resource hasn't arrived yet → tool folded into group. fixture.componentRef.setInput('message', makeMessage([tool])); fixture.detectChanges(); @@ -289,7 +296,7 @@ describe('AssistantMessageComponent', () => { // ui_resource arrives ~40ms after tool_result on the wire. The // displayBlocks computed must re-run on the McpAppStateService signal // update, or the tool stays folded forever. - mcpAppState.recordLive(makeUiResource('tooluse_mcp_app_2')); + mcpAppState.recordLive(VIEWED_SESSION, makeUiResource('tooluse_mcp_app_2')); fixture.detectChanges(); const blocks = component.displayBlocks(); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts index 83e256254..f1e9a7c36 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts @@ -13,6 +13,7 @@ import { OAuthConsentService, } from '../../../../services/oauth-consent/oauth-consent.service'; import { McpAppStateService } from '../../../services/mcp-apps/mcp-app-state.service'; +import { ChatStateService } from '../../../services/chat/chat-state.service'; import type { ToolResultData } from './tool-use/tool-renderer-registry.service'; // ────────────────────────────────────────────────────────────── @@ -306,6 +307,7 @@ export class AssistantMessageComponent { private consentService = inject(OAuthConsentService); private mcpAppState = inject(McpAppStateService); + private chatState = inject(ChatStateService); /** * Transforms content blocks into display blocks. @@ -386,8 +388,12 @@ export class AssistantMessageComponent { // signal here keeps `displayBlocks` reactive to a late-arriving // `ui_resource` — the computed re-runs when McpAppStateService // updates and the tool gets promoted retroactively (vs. staying - // folded into the group forever). - const hasMcpAppResource = this.mcpAppState.has(toolUse.toolUseId); + // folded into the group forever). Resources are held per + // conversation, so the lookup is scoped to the viewed one. + const hasMcpAppResource = this.mcpAppState.has( + this.chatState.viewedSessionId(), + toolUse.toolUseId, + ); if (promotedVisual || hasMcpAppResource) { // Promoted visuals and MCP Apps both need their own first-class diff --git a/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-actions/mcp-app-actions.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-actions/mcp-app-actions.component.spec.ts new file mode 100644 index 000000000..1b845b9f5 --- /dev/null +++ b/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-actions/mcp-app-actions.component.spec.ts @@ -0,0 +1,90 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { McpAppActionsComponent } from './mcp-app-actions.component'; +import type { McpAppCard } from '../../../../services/mcp-apps/mcp-app-card-state.service'; + +function card(over: Partial = {}): McpAppCard { + return { + cardId: `c${Math.random().toString(36).slice(2, 8)}`, + toolUseId: 'tu1', + toolName: 'board_snapshot', + arguments: {}, + content: [{ type: 'text', text: 'ok' }], + isError: false, + createdAt: '2026-01-01T00:00:00Z', + ...over, + }; +} + +describe('McpAppActionsComponent', () => { + let fixture: ComponentFixture; + + function render(cards: McpAppCard[]): string { + fixture.componentRef.setInput('cards', cards); + fixture.detectChanges(); + return (fixture.nativeElement.textContent ?? '').replace(/\s+/g, ' ').trim(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [McpAppActionsComponent], + }).compileComponents(); + fixture = TestBed.createComponent(McpAppActionsComponent); + }); + + it('collapses repeated successful calls into one summary line', () => { + const text = render([ + card({ toolName: 'board_snapshot' }), + card({ toolName: 'update_task' }), + card({ toolName: 'board_snapshot' }), + card({ toolName: 'board_snapshot' }), + ]); + expect(text).toContain('4 succeeded'); + expect(text).toContain('board_snapshot ×3'); + expect(text).toContain('update_task'); + // A repeated tool must not produce a row apiece — that's the wall of + // history this component exists to replace. + expect(text.match(/board_snapshot/g)).toHaveLength(1); + }); + + it('drops the ×N suffix for a single call', () => { + expect(render([card({ toolName: 'update_task' })])).toContain( + '1 succeeded update_task', + ); + }); + + it('lists each failure separately, with its error text', () => { + const text = render([ + card({ toolName: 'board_snapshot' }), + card({ toolName: 'update_task', isError: true, content: [{ type: 'text', text: 'task not found' }] }), + card({ toolName: 'delete_task', isError: true, content: [{ type: 'text', text: 'permission denied' }] }), + ]); + expect(text).toContain('1 succeeded'); + // One row per failure, each carrying its own tool name and message. + // (Asserted without whitespace between the name and the "failed" label — + // they're adjacent inline spans separated by a margin, not by text.) + expect(text.match(/failed/g)).toHaveLength(2); + expect(text).toContain('update_task'); + expect(text).toContain('task not found'); + expect(text).toContain('delete_task'); + expect(text).toContain('permission denied'); + }); + + it('omits the success line when everything failed', () => { + const text = render([card({ isError: true })]); + expect(text).not.toContain('succeeded'); + expect(text).toContain('failed'); + }); + + it('truncates a long error result', () => { + const text = render([ + card({ isError: true, content: [{ type: 'text', text: 'x'.repeat(400) }] }), + ]); + expect(text).toContain('…'); + expect(text).not.toContain('x'.repeat(250)); + }); + + it('renders nothing for an empty card list', () => { + expect(render([])).toBe(''); + }); +}); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-actions/mcp-app-actions.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-actions/mcp-app-actions.component.ts new file mode 100644 index 000000000..19c30d377 --- /dev/null +++ b/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-actions/mcp-app-actions.component.ts @@ -0,0 +1,114 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroExclamationTriangle } from '@ng-icons/heroicons/outline'; +import type { McpAppCard } from '../../../../services/mcp-apps/mcp-app-card-state.service'; + +/** + * Provenance detail for the tool calls an embedded MCP App ran on the + * user's behalf (MCP Apps PR #6, Option A — persisted by `card_store`). + * + * Presentational only: the disclosure that reveals it lives in the App + * frame's header (`McpAppFrameComponent`), which is where an App's actions + * belong — they're the App's doing, not the model's. The message list uses + * this same component for the orphan case where no frame can render. + * + * Successful runs deliberately collapse into ONE summary line + * (`board_snapshot ×2, update_task`) rather than a card apiece: an + * interactive App snapshots and mutates on nearly every user gesture, so a + * card-per-call turns a working board into a wall of history. Failures are + * the exception — they're listed individually, with their error text, + * because "what did this app do that didn't work" is the question the + * record exists to answer. + */ +@Component({ + selector: 'app-mcp-app-actions', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [provideIcons({ heroExclamationTriangle })], + host: { class: 'block' }, + template: ` +
    + @if (successSummary(); as summary) { +
    + + {{ succeeded().length }} succeeded + + {{ summary }} +
    + } + + @for (failure of failures(); track failure.card.cardId) { +
    +
    + } +
    + `, +}) +export class McpAppActionsComponent { + readonly cards = input.required(); + + protected readonly succeeded = computed(() => + this.cards().filter((card) => !card.isError), + ); + + /** + * Successful runs as `toolName ×N`, in first-seen order (cards arrive + * oldest-first, so the order is stable across renders). Null when + * nothing succeeded, so the template omits the line entirely. + */ + protected readonly successSummary = computed(() => { + const counts = new Map(); + for (const card of this.succeeded()) { + counts.set(card.toolName, (counts.get(card.toolName) ?? 0) + 1); + } + if (!counts.size) return null; + return [...counts.entries()] + .map(([name, count]) => (count > 1 ? `${name} ×${count}` : name)) + .join(', '); + }); + + protected readonly failures = computed(() => + this.cards() + .filter((card) => card.isError) + .map((card) => ({ card, message: resultText(card) })), + ); +} + +/** First 200 chars of the card's text result blocks, or null. */ +function resultText(card: McpAppCard): string | null { + const parts: string[] = []; + for (const block of card.content ?? []) { + const text = (block as { text?: unknown }).text; + if (typeof text === 'string' && text) parts.push(text); + } + const joined = parts.join('\n').trim(); + if (!joined) return null; + return joined.length > 200 ? `${joined.slice(0, 200)}…` : joined; +} diff --git a/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-card/mcp-app-card.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-card/mcp-app-card.component.ts deleted file mode 100644 index 351376e32..000000000 --- a/frontend/ai.client/src/app/session/components/message-list/components/mcp-app-card/mcp-app-card.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - input, -} from '@angular/core'; -import { NgIcon, provideIcons } from '@ng-icons/core'; -import { - heroExclamationTriangle, - heroPuzzlePiece, -} from '@ng-icons/heroicons/outline'; -import type { McpAppCard } from '../../../../services/mcp-apps/mcp-app-card-state.service'; - -/** - * Static historical card for an app-initiated tool call (MCP Apps PR #6, - * Option A). Rendered on reload from persisted provenance — read-only by - * design: the App iframe itself is not re-instantiated, so this records - * *what the embedded app ran on the user's behalf*, not an interactive - * surface. Live app-initiated calls render through the normal tool path - * (PR #5); this only appears after a refresh. - */ -@Component({ - selector: 'app-mcp-app-card', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgIcon], - providers: [ - provideIcons({ heroExclamationTriangle, heroPuzzlePiece }), - ], - host: { class: 'block' }, - template: ` -
    -
    -
    - - @if (argsPreview(); as args) { -
    {{ args }}
    - } - - @if (resultText(); as text) { -

    - {{ text }} -

    - } -
    - `, -}) -export class McpAppCardComponent { - readonly card = input.required(); - - protected readonly argsPreview = computed(() => { - const args = this.card().arguments; - if (!args || Object.keys(args).length === 0) return null; - let text: string; - try { - text = JSON.stringify(args); - } catch { - return null; - } - return text.length > 300 ? `${text.slice(0, 300)}…` : text; - }); - - protected readonly resultText = computed(() => { - const blocks = this.card().content ?? []; - const parts: string[] = []; - for (const block of blocks) { - const text = (block as { text?: unknown }).text; - if (typeof text === 'string' && text) parts.push(text); - } - const joined = parts.join('\n').trim(); - if (!joined) return null; - return joined.length > 500 ? `${joined.slice(0, 500)}…` : joined; - }); -} diff --git a/frontend/ai.client/src/app/session/components/message-list/components/tool-use/renderers/mcp-app-frame.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/tool-use/renderers/mcp-app-frame.component.ts index 612f18e3b..3d3e8b7dd 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/tool-use/renderers/mcp-app-frame.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/tool-use/renderers/mcp-app-frame.component.ts @@ -22,8 +22,11 @@ import { McpAppBridge } from '../../../../../services/mcp-apps/mcp-app-bridge'; import { McpAppProxyService } from '../../../../../services/mcp-apps/mcp-app-proxy.service'; import { McpAppMessageService } from '../../../../../services/mcp-apps/mcp-app-message.service'; import { McpAppConsentService } from '../../../../../services/mcp-apps/mcp-app-consent.service'; +import { McpAppTeardownService } from '../../../../../services/mcp-apps/mcp-app-teardown.service'; import { buildProxyUrl } from '../../../../../services/mcp-apps/proxy-url'; import { McpAppConsentPromptComponent } from '../../mcp-app-consent-prompt/mcp-app-consent-prompt.component'; +import { McpAppActionsComponent } from '../../mcp-app-actions/mcp-app-actions.component'; +import { McpAppCardStateService } from '../../../../../services/mcp-apps/mcp-app-card-state.service'; import { JsonSyntaxHighlightPipe } from '../json-syntax-highlight.pipe'; import type { DisplayMode } from '../../../../../services/mcp-apps/mcp-app-protocol'; import { ChatRequestService } from '../../../../../services/chat/chat-request.service'; @@ -43,14 +46,25 @@ import { SessionService } from '../../../../../services/session/session.service' * * The whole surface is dark until the backend host flag is flipped (PR #7), * so in practice no `ui_resource` arrives and the registry never resolves - * here. When it has no resource for its `toolUseId` (e.g. after a reload — - * the inline event doesn't re-hydrate) it renders nothing and the tool-use - * card falls back to the default renderer path. + * here. When it has no resource for its `toolUseId` it renders nothing and + * the tool-use card falls back to the default renderer path — in practice + * that means an environment with no mcp-sandbox origin, since resources + * themselves DO survive a reload via the `GET /messages` `uiResources` + * sidecar (`McpAppStateService.seedFromHydration`). + * + * Because the frame comes back on reload, it also owns the record of what + * the App ran while it was up: the header's actions chip discloses the + * persisted provenance cards for this `toolUseId` (see + * `McpAppActionsComponent`). */ @Component({ selector: 'app-mcp-app-frame', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [McpAppConsentPromptComponent, JsonSyntaxHighlightPipe], + imports: [ + McpAppConsentPromptComponent, + McpAppActionsComponent, + JsonSyntaxHighlightPipe, + ], styles: ` :host { display: block; @@ -231,33 +245,67 @@ import { SessionService } from '../../../../../services/session/session.service' Exit fullscreen } @else { - + + +

}

+ @if ( + displayMode() !== 'fullscreen' && actionsExpanded() && appActions().length + ) { +
+
+ Run by this app +
+ +
+ } + @if (displayMode() !== 'fullscreen' && detailsExpanded()) {
(''); private readonly mcpAppState = inject(McpAppStateService); + private readonly appCardState = inject(McpAppCardStateService); private readonly mcpAppProxy = inject(McpAppProxyService); private readonly mcpAppMessage = inject(McpAppMessageService); private readonly mcpAppConsent = inject(McpAppConsentService); + private readonly mcpAppTeardown = inject(McpAppTeardownService); private readonly chatRequest = inject(ChatRequestService); private readonly chatState = inject(ChatStateService); private readonly conversation = inject(SessionService); @@ -434,13 +484,17 @@ export class McpAppFrameComponent implements ToolResultRenderer { ); private bridge: McpAppBridge | null = null; + /** Deregisters this frame's bridge from the navigation teardown registry. */ + private unregisterBridge: (() => void) | null = null; private readonly nonce = this.win?.crypto?.randomUUID?.() ?? `n-${Math.random().toString(36).slice(2)}`; /** The UI resource for this tool invocation (undefined ⇒ render nothing). */ protected readonly resource = computed(() => { const id = this.toolUseId(); - return id ? this.mcpAppState.get(id) : undefined; + return id + ? this.mcpAppState.get(this.chatState.viewedSessionId(), id) + : undefined; }); /** Whether the header's server icon `` failed to load (→ glyph). */ @@ -449,6 +503,44 @@ export class McpAppFrameComponent implements ToolResultRenderer { /** Whether the request/response details strip is expanded (the `` toggle). */ protected readonly detailsExpanded = signal(false); + /** Whether the app-run actions strip is expanded (the count chip). */ + protected readonly actionsExpanded = signal(false); + + /** + * Tool calls this App ran on the user's behalf, persisted by PR #6's + * card store and keyed by the *originating* tool-use id — i.e. this + * frame. Only ever populated after a reload: live app-initiated calls + * already surface through the in-memory broker as ordinary tool rows, + * and the store isn't re-read mid-conversation. + */ + protected readonly appActions = computed(() => + this.appCardState.cardsFor(this.toolUseId()), + ); + + private readonly appActionFailures = computed( + () => this.appActions().filter((card) => card.isError).length, + ); + + /** `3 actions` / `3 actions · 1 failed` — the chip's glanceable summary. */ + protected readonly appActionsChipText = computed(() => { + const total = this.appActions().length; + const failed = this.appActionFailures(); + const base = `${total} ${total === 1 ? 'action' : 'actions'}`; + return failed ? `${base} · ${failed} failed` : base; + }); + + protected readonly appActionsLabel = computed(() => { + const verb = this.actionsExpanded() ? 'Hide' : 'Show'; + return `${verb} the ${this.appActionsChipText()} run by this app`; + }); + + /** Danger tint only when something failed; otherwise a quiet chip. */ + protected readonly appActionsChipClasses = computed(() => + this.appActionFailures() + ? 'text-state-danger-600 dark:text-state-danger-400' + : 'text-gray-500 dark:text-gray-400', + ); + /** * Server display name for the header. Prefers the backend-resolved * `serverName` on the resource (serverInfo title/name → `ui://` authority), @@ -511,6 +603,10 @@ export class McpAppFrameComponent implements ToolResultRenderer { this.detailsExpanded.update((v) => !v); } + protected toggleActions(): void { + this.actionsExpanded.update((v) => !v); + } + /** * Title-case a `ui:///…` authority as a server-name fallback * (`ui://excalidraw/canvas` → "Excalidraw"), mirroring the backend's @@ -534,7 +630,9 @@ export class McpAppFrameComponent implements ToolResultRenderer { */ protected readonly partialInput = computed(() => { const id = this.toolUseId(); - return id ? this.mcpAppState.getPartialInput(id) : undefined; + return id + ? this.mcpAppState.getPartialInput(this.chatState.viewedSessionId(), id) + : undefined; }); /** @@ -789,7 +887,12 @@ export class McpAppFrameComponent implements ToolResultRenderer { this.doc.body.style.overflow = this.lockedBodyOverflow; this.lockedBodyOverflow = null; } - this.bridge?.dispose('component-destroyed'); + // Deregister first: a navigation-driven teardownAll() already fired + // the notification while this iframe was alive, and re-firing here + // would only reset a grace window whose View is already gone. + this.unregisterBridge?.(); + this.unregisterBridge = null; + void this.bridge?.dispose('component-destroyed'); }); } @@ -859,6 +962,7 @@ export class McpAppFrameComponent implements ToolResultRenderer { return resulting; }, }); + this.unregisterBridge = this.mcpAppTeardown.register(this.bridge); this.bridge.onSizeChanged((_w, h) => { if (h > 0) this.frameHeight.set(Math.ceil(h)); }); diff --git a/frontend/ai.client/src/app/session/components/message-list/message-list.component.html b/frontend/ai.client/src/app/session/components/message-list/message-list.component.html index 918d304aa..968aa4e92 100644 --- a/frontend/ai.client/src/app/session/components/message-list/message-list.component.html +++ b/frontend/ai.client/src/app/session/components/message-list/message-list.component.html @@ -160,18 +160,21 @@
} - - @if (hasMcpAppCards()) { + + @if (orphanedMcpAppCards().length) {
- @for (card of mcpAppCards(); track card.cardId) { - - } +
+ Run by app +
+
} diff --git a/frontend/ai.client/src/app/session/components/message-list/message-list.component.ts b/frontend/ai.client/src/app/session/components/message-list/message-list.component.ts index 5ec425b2b..65a0fd634 100644 --- a/frontend/ai.client/src/app/session/components/message-list/message-list.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/message-list.component.ts @@ -16,9 +16,13 @@ import { ArtifactPanelComponent } from './components/artifact/artifact-panel.com import { ArtifactStateService } from '../../services/artifacts/artifact-state.service'; import { SharedArtifactCardComponent } from '../../../shared/artifact/shared-artifact-card.component'; import type { SharedConversationArtifact } from '../../services/share/share.service'; -import { McpAppCardComponent } from './components/mcp-app-card/mcp-app-card.component'; +import { McpAppActionsComponent } from './components/mcp-app-actions/mcp-app-actions.component'; +import { McpAppStateService } from '../../services/mcp-apps/mcp-app-state.service'; import { AgentFeedbackLinkComponent } from '../../../agents/components/agent-feedback-link.component'; -import { McpAppCardStateService } from '../../services/mcp-apps/mcp-app-card-state.service'; +import { + McpAppCardStateService, + type McpAppCard, +} from '../../services/mcp-apps/mcp-app-card-state.service'; import { OAuthConsentRequest, OAuthConsentService, @@ -47,7 +51,7 @@ import { StreamParserService } from '../../services/chat/stream-parser.service'; ArtifactCardComponent, ArtifactPanelComponent, SharedArtifactCardComponent, - McpAppCardComponent, + McpAppActionsComponent, AgentFeedbackLinkComponent, ], templateUrl: './message-list.component.html', @@ -122,6 +126,7 @@ export class MessageListComponent { private compactionSummary = inject(CompactionSummaryService); private artifactState = inject(ArtifactStateService); private mcpAppCardState = inject(McpAppCardStateService); + private mcpAppState = inject(McpAppStateService); private chatStateService = inject(ChatStateService); private streamParser = inject(StreamParserService); @@ -202,9 +207,28 @@ export class MessageListComponent { () => this.retryNotice() ?? this.stallNotice(), ); - /** Persisted app-initiated tool cards, hydrated on reload (PR #6). */ - protected mcpAppCards = this.mcpAppCardState.cards; - protected hasMcpAppCards = this.mcpAppCardState.hasCards; + /** + * Persisted app-initiated tool cards (PR #6) that have nowhere better to + * go. Normally these surface behind their own App frame's header, keyed + * by the originating tool-use id — that's the whole point of the frame's + * actions chip. But a frame only renders once its `ui_resource` carries a + * `sandboxOrigin` (no mcp-sandbox stack → no frame), and without this + * fallback those cards would vanish silently. Provenance for a tool an + * app ran against the user's account is not something to drop on the + * floor, so orphans get a standalone box — still summarized, never a card + * apiece. + */ + protected orphanedMcpAppCards = computed(() => + this.mcpAppCardState + .cards() + .filter( + (card) => + !this.mcpAppState.get( + this.chatStateService.viewedSessionId(), + card.toolUseId, + )?.sandboxOrigin, + ), + ); /** * The feedback link needs something to give feedback *about*, so it waits for a turn to diff --git a/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts b/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts index 4da59345c..a906a620e 100644 --- a/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts +++ b/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts @@ -594,13 +594,15 @@ export class StreamParserService { onUiResource: (data: UiResourceEvent) => { // Inline event (arrives right after its tool_result, mid-stream), // unlike the post-message_stop side channels above — just record - // it keyed by toolUseId. The tool-use renderer picks it up - // reactively and swaps in the MCP App frame. Viewed-session only: - // McpAppStateService is reset on route change, so recording for a - // background conversation would be wiped before it could render. - if (this.isViewedSession(state)) { - this.mcpAppState.recordLive(data); - } + // it under this stream's own session, keyed by toolUseId. The + // tool-use renderer picks it up reactively and swaps in the MCP App + // frame. Deliberately NOT viewed-session-scoped: McpAppStateService + // retains per conversation rather than resetting on route change, so + // a background conversation's App is there when the user navigates + // to it. Dropping it here would lose it for good — the inline event + // never re-streams and the persisted replay rides on a `GET + // /messages` that navigate-back skips. + this.mcpAppState.recordLive(state.sessionId, data); }, onToolInputPartial: (data: ToolInputPartialEvent) => { @@ -608,10 +610,13 @@ export class StreamParserService { // UI tool's args are still streaming (after early frame mount). Record // the latest healed prefix keyed by toolUseId; the frame relays it to // the App as `ui/notifications/tool-input-partial` for progressive - // rendering (e.g. Excalidraw's guided camera tour). - if (this.isViewedSession(state)) { - this.mcpAppState.recordPartialInput(data.toolUseId, data.arguments); - } + // rendering (e.g. Excalidraw's guided camera tour). Recorded under + // this stream's own session, same as the resource above. + this.mcpAppState.recordPartialInput( + state.sessionId, + data.toolUseId, + data.arguments, + ); }, onSessionTitle: (data: SessionTitleEvent) => { diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.spec.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.spec.ts index 5585bc3d3..c108ca858 100644 --- a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.spec.ts +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.spec.ts @@ -529,16 +529,97 @@ describe('McpAppBridge', () => { expect(h.proxy.byId('p1').result).toEqual({}); }); - it('dispose() sends resource-teardown after init and detaches the listener', () => { + it('dispose() sends resource-teardown and stays attached through the grace window', async () => { handshake(h); h.host.deliver( { jsonrpc: '2.0', method: 'ui/notifications/initialized', nonce: NONCE }, h.proxy, ); - h.bridge.dispose('bye'); + const settled = h.bridge.dispose('bye', 5); const td = h.proxy.byMethod('ui/resource-teardown'); expect(td).toHaveLength(1); expect(td[0].params).toEqual({ reason: 'bye' }); + // Still listening: the App has been told it is going away and may answer + // by saving its state, and detaching here would drop that message. + expect(h.host.attached).toBe(true); + + await settled; + expect(h.host.attached).toBe(false); + }); + + it('dispose() detaches immediately when the View never initialized', async () => { + handshake(h); + await h.bridge.dispose('bye', 10_000); + // Nothing to notify and nothing to wait for — no teardown, no grace. + expect(h.proxy.byMethod('ui/resource-teardown')).toHaveLength(0); + expect(h.host.attached).toBe(false); + }); + + it('dispose() resolves on the ack without burning the whole grace window', async () => { + handshake(h); + h.host.deliver( + { jsonrpc: '2.0', method: 'ui/notifications/initialized', nonce: NONCE }, + h.proxy, + ); + // A grace window long enough that only the ack can finish this test. + const settled = h.bridge.dispose('bye', 60_000); + const id = h.proxy.byMethod('ui/resource-teardown')[0].id; + h.host.deliver({ jsonrpc: '2.0', id, nonce: NONCE, result: {} }, h.proxy); + + await settled; + expect(h.host.attached).toBe(false); + }); + + it('still proxies a tools/call the App makes in response to teardown', async () => { + handshake(h); + h.host.deliver( + { jsonrpc: '2.0', method: 'ui/notifications/initialized', nonce: NONCE }, + h.proxy, + ); + h.proxyToolCall.mockResolvedValue({ content: [], isError: false }); + + const settled = h.bridge.dispose('conversation-change', 50); + // This is the whole point of the grace window: an App that answers + // teardown by flushing its state must still reach its server. + h.host.deliver( + { + jsonrpc: '2.0', + id: 'save-1', + method: 'tools/call', + nonce: NONCE, + params: { name: 'save_board', arguments: { dirty: true } }, + }, + h.proxy, + ); + expect(h.proxyToolCall).toHaveBeenCalledWith('save_board', { dirty: true }); + + await settled; + // ...and once the window closes, late messages are ignored. + h.proxyToolCall.mockClear(); + h.host.deliver( + { + jsonrpc: '2.0', + id: 'save-2', + method: 'tools/call', + nonce: NONCE, + params: { name: 'save_board', arguments: {} }, + }, + h.proxy, + ); + expect(h.proxyToolCall).not.toHaveBeenCalled(); + }); + + it('repeat dispose() calls share the first teardown window', async () => { + handshake(h); + h.host.deliver( + { jsonrpc: '2.0', method: 'ui/notifications/initialized', nonce: NONCE }, + h.proxy, + ); + const first = h.bridge.dispose('nav', 5); + const second = h.bridge.dispose('component-destroyed', 5); + // One notification, not two — a second reason must not re-arm the window. + expect(h.proxy.byMethod('ui/resource-teardown')).toHaveLength(1); + await Promise.all([first, second]); expect(h.host.attached).toBe(false); }); diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.ts index cd5227166..383b298da 100644 --- a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.ts +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-bridge.ts @@ -71,6 +71,13 @@ import { isRequest, } from './mcp-app-protocol'; +/** + * How long the bridge keeps listening after sending `ui/resource-teardown`. + * Long enough for the View to ack and post a save call, short enough that a + * dead App never holds up a navigation that awaits it. + */ +export const DEFAULT_TEARDOWN_GRACE_MS = 1500; + /** Minimal window surface the bridge needs (eases testing). */ export interface BridgeHostWindow { addEventListener( @@ -172,6 +179,10 @@ export class McpAppBridge { /** Set on the View's `ui/notifications/initialized`. */ private viewInitialized = false; private disposed = false; + /** Set once the listener is gone; `disposed` only means teardown began. */ + private detached = false; + /** In-flight teardown, so repeat dispose() calls await the same window. */ + private teardownSettled: Promise | null = null; /** Notifications deferred until the View reports `initialized`. */ private readonly preInitQueue: Array<{ method: string; params: unknown }> = []; @@ -211,16 +222,46 @@ export class McpAppBridge { } /** - * Tear down: best-effort `ui/resource-teardown` toward the View, then - * detach. Safe to call multiple times. + * Tear down: `ui/resource-teardown` toward the View, then detach. + * Safe to call multiple times. Resolves when the View acks or the grace + * window expires — callers that can afford to wait (navigation) should + * await it; `onDestroy` cannot, and doesn't. + * + * The spec's teardown notification exists so the App can flush state to + * its server before it dies (SEP-1865: the host SHOULD wait for a + * response "to prevent data loss"). Detaching in the same tick as the + * send defeated exactly that: the ack landed on a removed listener, and + * an App that answered teardown by calling a save tool had its + * postMessage dropped on the floor. So we keep listening through the + * grace window — the App's flush is proxied over HTTP from the *host* + * page, so once its message is in, the request outlives the iframe. */ - dispose(reason = 'host-teardown'): void { - if (this.disposed) return; + dispose(reason = 'host-teardown', graceMs = DEFAULT_TEARDOWN_GRACE_MS): Promise { + if (this.disposed) return this.teardownSettled ?? Promise.resolve(); this.disposed = true; - if (this.viewInitialized) { - // Fire-and-forget: we're going away regardless of the ack. - this.sendRequest(M_RESOURCE_TEARDOWN, { reason }).catch(() => undefined); + + if (!this.viewInitialized) { + this.detach(); + return Promise.resolve(); } + + const acked = this.sendRequest(M_RESOURCE_TEARDOWN, { reason }).then( + () => undefined, + () => undefined, + ); + this.teardownSettled = Promise.race([ + acked, + new Promise((resolve) => setTimeout(resolve, graceMs)), + ]).then(() => { + this.detach(); + }); + return this.teardownSettled; + } + + /** Remove the listener and fail anything still in flight. */ + private detach(): void { + if (this.detached) return; + this.detached = true; if (this.listener) { this.d.hostWindow.removeEventListener('message', this.listener); this.listener = null; @@ -239,7 +280,11 @@ export class McpAppBridge { // --- inbound ------------------------------------------------------------ private onMessage(ev: MessageEvent): void { - if (this.disposed) return; + // Gated on `detached`, NOT `disposed`: between dispose() and the end of + // the teardown grace window the bridge is still live on purpose, so the + // App's teardown ack — and any save call it makes in response — are + // still served. + if (this.detached) return; const proxyWindow = this.d.getProxyWindow(); // Source + origin gate. The proxy page is served from sandboxOrigin, so // its window's origin is a real URL (the null-origin inner frame only diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.spec.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.spec.ts index 5fa203a82..8dbf4d425 100644 --- a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.spec.ts +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.spec.ts @@ -54,4 +54,24 @@ describe('McpAppCardStateService', () => { expect(svc.hasCards()).toBe(false); expect(svc.cards()).toEqual([]); }); + + describe('grouping by originating tool use', () => { + it('groups cards under the App frame that ran them, oldest-first', () => { + svc.seedFromHydration([ + card({ cardId: 'b', toolUseId: 'tu1', createdAt: '2026-01-02T00:00:00Z' }), + card({ cardId: 'c', toolUseId: 'tu2', createdAt: '2026-01-03T00:00:00Z' }), + card({ cardId: 'a', toolUseId: 'tu1', createdAt: '2026-01-01T00:00:00Z' }), + ]); + expect(svc.cardsFor('tu1').map((c) => c.cardId)).toEqual(['a', 'b']); + expect(svc.cardsFor('tu2').map((c) => c.cardId)).toEqual(['c']); + }); + + it('returns the same empty array for a miss or a missing id', () => { + svc.seedFromHydration([card({ toolUseId: 'tu1' })]); + // Stable reference — a fresh array each call would churn the frame's + // computed on every change-detection pass. + expect(svc.cardsFor('nope')).toBe(svc.cardsFor('other')); + expect(svc.cardsFor(undefined)).toEqual([]); + }); + }); }); diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.ts index 15e3c7a69..9a827c4c6 100644 --- a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.ts +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-card-state.service.ts @@ -15,6 +15,9 @@ export interface McpAppCard { producedByMessageIndex?: number | null; } +/** Stable empty result so `cardsFor` misses don't churn change detection. */ +const EMPTY_CARDS: readonly McpAppCard[] = Object.freeze([]); + /** * Reload hydration for app-initiated tool-call cards (MCP Apps PR #6). * @@ -32,6 +35,14 @@ export interface McpAppCard { * calls on conversation change. There is deliberately no `recordLive`: * live app-initiated calls already render through the normal tool path * (PR #5), so a live card would double-render. + * + * A card's `toolUseId` is the *originating* tool call — the one that + * produced the App's `ui_resource` — not a per-call id (see + * `McpAppProxyService.proxyToolCall`). So `byToolUse` groups every action + * an App ran back onto the frame that ran them, which is where they + * surface: behind the App frame's header, successes collapsed to a single + * summary line. Cards whose frame can't render fall back to a standalone + * box in the message list. */ @Injectable({ providedIn: 'root' }) export class McpAppCardStateService { @@ -46,6 +57,28 @@ export class McpAppCardStateService { readonly hasCards = computed(() => this.byId().size > 0); + /** + * Cards grouped by the originating tool-use id (the App frame that ran + * them), each group oldest-first. Computed once per card change rather + * than filtered per frame, so a conversation with many App frames does + * one pass instead of one-per-frame. + */ + readonly byToolUse = computed>(() => { + const grouped = new Map(); + for (const card of this.cards()) { + const group = grouped.get(card.toolUseId); + if (group) group.push(card); + else grouped.set(card.toolUseId, [card]); + } + return grouped; + }); + + /** Cards run by the App frame with this tool-use id, oldest-first. */ + cardsFor(toolUseId: string | undefined): readonly McpAppCard[] { + if (!toolUseId) return EMPTY_CARDS; + return this.byToolUse().get(toolUseId) ?? EMPTY_CARDS; + } + /** * Seed cards fetched from the app-api list endpoint on conversation * load. Non-clobbering by `cardId` so a slow response can't undo state diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.spec.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.spec.ts index 1bd71b739..e34ad0dec 100644 --- a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.spec.ts +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.spec.ts @@ -3,6 +3,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { McpAppStateService } from './mcp-app-state.service'; import type { UiResourceEvent } from '../../../shared/utils/stream-parser'; +const SESSION_A = 'sess-a'; +const SESSION_B = 'sess-b'; + function ev(toolUseId: string, html = '

hi

'): UiResourceEvent { return { type: 'ui_resource', @@ -26,87 +29,136 @@ describe('McpAppStateService', () => { }); it('starts empty', () => { - expect(svc.hasApps()).toBe(false); - expect(svc.has('tu-1')).toBe(false); - expect(svc.get('tu-1')).toBeUndefined(); + expect(svc.has(SESSION_A, 'tu-1')).toBe(false); + expect(svc.get(SESSION_A, 'tu-1')).toBeUndefined(); }); it('records and retrieves a resource by toolUseId', () => { const e = ev('tu-1'); - svc.recordLive(e); - expect(svc.has('tu-1')).toBe(true); - expect(svc.get('tu-1')).toEqual(e); - expect(svc.hasApps()).toBe(true); + svc.recordLive(SESSION_A, e); + expect(svc.has(SESSION_A, 'tu-1')).toBe(true); + expect(svc.get(SESSION_A, 'tu-1')).toEqual(e); }); it('last write wins for the same toolUseId', () => { - svc.recordLive(ev('tu-1', '')); - svc.recordLive(ev('tu-1', '')); - expect(svc.get('tu-1')?.html).toBe(''); + svc.recordLive(SESSION_A, ev('tu-1', '')); + svc.recordLive(SESSION_A, ev('tu-1', '')); + expect(svc.get(SESSION_A, 'tu-1')?.html).toBe(''); }); it('keeps distinct invocations separate', () => { - svc.recordLive(ev('tu-1')); - svc.recordLive(ev('tu-2')); - expect(svc.get('tu-1')?.resourceUri).toBe('ui://srv/tu-1'); - expect(svc.get('tu-2')?.resourceUri).toBe('ui://srv/tu-2'); + svc.recordLive(SESSION_A, ev('tu-1')); + svc.recordLive(SESSION_A, ev('tu-2')); + expect(svc.get(SESSION_A, 'tu-1')?.resourceUri).toBe('ui://srv/tu-1'); + expect(svc.get(SESSION_A, 'tu-2')?.resourceUri).toBe('ui://srv/tu-2'); }); - it('reset() drops everything (conversation teardown)', () => { - svc.recordLive(ev('tu-1')); - svc.recordPartialInput('tu-1', { elements: [] }); + it('reset() drops everything (full teardown)', () => { + svc.recordLive(SESSION_A, ev('tu-1')); + svc.recordPartialInput(SESSION_A, 'tu-1', { elements: [] }); svc.reset(); - expect(svc.hasApps()).toBe(false); - expect(svc.has('tu-1')).toBe(false); - expect(svc.getPartialInput('tu-1')).toBeUndefined(); + expect(svc.has(SESSION_A, 'tu-1')).toBe(false); + expect(svc.getPartialInput(SESSION_A, 'tu-1')).toBeUndefined(); + }); + + it('treats a null session id as empty (no viewed conversation)', () => { + svc.recordLive(SESSION_A, ev('tu-1')); + expect(svc.has(null, 'tu-1')).toBe(false); + expect(svc.get(null, 'tu-1')).toBeUndefined(); + expect(svc.getPartialInput(null, 'tu-1')).toBeUndefined(); + }); + + describe('per-conversation retention', () => { + // The regression this shape exists to prevent: leaving a conversation + // and coming back dropped every App frame to a plain tool card, because + // the registry was reset on navigation while `loadMessagesForSession` + // skips the `GET /messages` (and its `uiResources` sidecar) for a + // conversation whose messages are already cached. + it('keeps a conversation\'s resources across a visit to another one', () => { + svc.seedFromHydration(SESSION_A, [ev('tu-1')]); + svc.recordLive(SESSION_A, ev('tu-2')); + + // User navigates to B (which has its own App), then back to A. + svc.recordLive(SESSION_B, ev('tu-3')); + + expect(svc.has(SESSION_A, 'tu-1')).toBe(true); + expect(svc.has(SESSION_A, 'tu-2')).toBe(true); + }); + + it('scopes lookups to their own conversation', () => { + svc.recordLive(SESSION_A, ev('tu-1')); + expect(svc.has(SESSION_B, 'tu-1')).toBe(false); + expect(svc.get(SESSION_B, 'tu-1')).toBeUndefined(); + }); + + it('retains a background stream\'s resource for when the user opens it', () => { + // recordLive is no longer viewed-session-gated: a conversation + // streaming in the background records into its own bucket. + svc.recordLive(SESSION_B, ev('tu-9')); + expect(svc.get(SESSION_B, 'tu-9')?.resourceUri).toBe('ui://srv/tu-9'); + }); }); describe('recordPartialInput', () => { it('records and retrieves the latest streamed partial input', () => { - expect(svc.getPartialInput('tu-1')).toBeUndefined(); - svc.recordPartialInput('tu-1', { elements: [{ type: 'rect' }] }); - expect(svc.getPartialInput('tu-1')).toEqual({ + expect(svc.getPartialInput(SESSION_A, 'tu-1')).toBeUndefined(); + svc.recordPartialInput(SESSION_A, 'tu-1', { + elements: [{ type: 'rect' }], + }); + expect(svc.getPartialInput(SESSION_A, 'tu-1')).toEqual({ elements: [{ type: 'rect' }], }); }); it('last write wins (the backend streams a growing healed prefix)', () => { - svc.recordPartialInput('tu-1', { elements: [{ type: 'rect' }] }); - svc.recordPartialInput('tu-1', { + svc.recordPartialInput(SESSION_A, 'tu-1', { + elements: [{ type: 'rect' }], + }); + svc.recordPartialInput(SESSION_A, 'tu-1', { elements: [{ type: 'rect' }, { type: 'cameraUpdate' }], }); expect( - (svc.getPartialInput('tu-1')?.['elements'] as unknown[]).length, + (svc.getPartialInput(SESSION_A, 'tu-1')?.['elements'] as unknown[]) + .length, ).toBe(2); }); it('keeps partial input separate per toolUseId', () => { - svc.recordPartialInput('tu-1', { a: 1 }); - svc.recordPartialInput('tu-2', { b: 2 }); - expect(svc.getPartialInput('tu-1')).toEqual({ a: 1 }); - expect(svc.getPartialInput('tu-2')).toEqual({ b: 2 }); + svc.recordPartialInput(SESSION_A, 'tu-1', { a: 1 }); + svc.recordPartialInput(SESSION_A, 'tu-2', { b: 2 }); + expect(svc.getPartialInput(SESSION_A, 'tu-1')).toEqual({ a: 1 }); + expect(svc.getPartialInput(SESSION_A, 'tu-2')).toEqual({ b: 2 }); + }); + + it('keeps partial input separate per conversation', () => { + svc.recordPartialInput(SESSION_A, 'tu-1', { a: 1 }); + expect(svc.getPartialInput(SESSION_B, 'tu-1')).toBeUndefined(); }); }); describe('seedFromHydration', () => { it('seeds persisted resources so the frame re-renders on reload', () => { - svc.seedFromHydration([ev('tu-1'), ev('tu-2')]); - expect(svc.has('tu-1')).toBe(true); - expect(svc.get('tu-2')?.resourceUri).toBe('ui://srv/tu-2'); - expect(svc.hasApps()).toBe(true); + svc.seedFromHydration(SESSION_A, [ev('tu-1'), ev('tu-2')]); + expect(svc.has(SESSION_A, 'tu-1')).toBe(true); + expect(svc.get(SESSION_A, 'tu-2')?.resourceUri).toBe('ui://srv/tu-2'); }); it('is a no-op for an empty list', () => { - svc.seedFromHydration([]); - expect(svc.hasApps()).toBe(false); + svc.seedFromHydration(SESSION_A, []); + expect(svc.has(SESSION_A, 'tu-1')).toBe(false); }); it('does not clobber a live recordLive entry (non-clobbering)', () => { - svc.recordLive(ev('tu-1', '')); + svc.recordLive(SESSION_A, ev('tu-1', '')); // A slow hydration response arriving after the live event must not // overwrite the fresher live resource. - svc.seedFromHydration([ev('tu-1', '')]); - expect(svc.get('tu-1')?.html).toBe(''); + svc.seedFromHydration(SESSION_A, [ev('tu-1', '')]); + expect(svc.get(SESSION_A, 'tu-1')?.html).toBe(''); + }); + + it('seeds into the named conversation only', () => { + svc.seedFromHydration(SESSION_A, [ev('tu-1')]); + expect(svc.has(SESSION_B, 'tu-1')).toBe(false); }); }); }); diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.ts index 4a1a5ca44..9e276c2a5 100644 --- a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.ts +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-state.service.ts @@ -1,52 +1,61 @@ -import { Injectable, computed, signal } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import type { UiResourceEvent } from '../../../shared/utils/stream-parser'; /** - * Per-conversation registry of MCP App UI resources (SEP-1865), keyed by the - * originating `toolUseId`. Structural sibling of `ArtifactStateService` / - * `CompactionSummaryService`: a live SSE path (`recordLive`) and a `reset()` - * the session page calls on conversation change. + * Registry of MCP App UI resources (SEP-1865), keyed by conversation and then + * by the originating `toolUseId`. * - * The `ui_resource` event is INLINE (it arrives right after its - * `tool_result`), so a refreshed conversation re-streams nothing. To survive - * a reload, app-api persists each resource and replays it on the - * `GET /messages` response's `uiResources` sidecar; `seedFromHydration` - * re-seeds this registry from that list so the `mcp-app-frame` re-renders. - * Iframes otherwise persist for the lifetime of the conversation per the - * scoping doc; teardown is on `reset()`. + * Unlike `ArtifactStateService` / `CompactionSummaryService` — which are + * viewed-session-scoped and reset on conversation change — this registry + * RETAINS every conversation it has seen for the lifetime of the SPA session, + * mirroring the message cache in `MessageMapService`. That retention is the + * whole point: the `ui_resource` event is inline (it arrives at the tool's + * `content_block_start`, mid-stream) and never re-streams, and the only + * server-side replay is the `uiResources` sidecar on `GET /messages` — a + * request `loadMessagesForSession` deliberately skips when the conversation's + * messages are already in memory. A registry that reset on navigation + * therefore had no way back: leaving a conversation and returning to it + * dropped every App frame to a plain tool card until a hard refresh. + * + * Reads are scoped to a conversation so a `toolUseId` can only ever resolve + * inside the conversation that produced it. Writes carry their own session id + * (the streaming session, which is not necessarily the viewed one — a + * background conversation's Apps are recorded too, and are there when the user + * navigates back). + * + * Iframe teardown is not this registry's job: a frame unmounts when its + * message-list component is destroyed on conversation change. * * The whole surface is dark until the backend `AGENTCORE_MCP_APPS_HOST_ENABLED` * flag is flipped, so when it's off nothing is recorded or hydrated. */ @Injectable({ providedIn: 'root' }) export class McpAppStateService { - private readonly byToolUseId = signal>( - new Map(), - ); + private readonly bySession = signal< + ReadonlyMap> + >(new Map()); /** - * Latest streamed partial tool input per `toolUseId` (SEP-1865 - * `tool-input-partial`). Populated while a UI tool's arguments are still - * streaming, after the frame mounts early; the frame relays each healed - * prefix to the App for progressive rendering. Last write wins (the backend - * sends the growing healed prefix). Cleared on `reset()`. + * Latest streamed partial tool input per conversation and `toolUseId` + * (SEP-1865 `tool-input-partial`). Populated while a UI tool's arguments are + * still streaming, after the frame mounts early; the frame relays each + * healed prefix to the App for progressive rendering. Last write wins (the + * backend sends the growing healed prefix). Retained alongside the + * resources. */ - private readonly partialInputByToolUseId = signal< - ReadonlyMap> + private readonly partialInputBySession = signal< + ReadonlyMap>> >(new Map()); - /** True once any MCP App resource has been recorded this conversation. */ - readonly hasApps = computed(() => this.byToolUseId().size > 0); - /** * Record the UI resource for a tool invocation. Last write wins — a tool * that re-emits for the same `toolUseId` replaces the prior resource * (the iframe rebinds to the new HTML). New invocations get new ids. */ - recordLive(event: UiResourceEvent): void { - const next = new Map(this.byToolUseId()); - next.set(event.toolUseId, event); - this.byToolUseId.set(next); + recordLive(sessionId: string, event: UiResourceEvent): void { + this.bySession.update(map => + writeEntry(map, sessionId, event.toolUseId, event), + ); } /** @@ -55,13 +64,20 @@ export class McpAppStateService { * `toolUseId` so a slow response can't undo a live `recordLive` entry * (matches `ArtifactStateService.seedFromHydration` semantics). */ - seedFromHydration(list: readonly UiResourceEvent[]): void { + seedFromHydration( + sessionId: string, + list: readonly UiResourceEvent[], + ): void { if (!list.length) return; - const next = new Map(this.byToolUseId()); - for (const event of list) { - if (!next.has(event.toolUseId)) next.set(event.toolUseId, event); - } - this.byToolUseId.set(next); + this.bySession.update(map => { + const existing = map.get(sessionId); + const next = new Map(existing ?? []); + for (const event of list) { + if (!next.has(event.toolUseId)) next.set(event.toolUseId, event); + } + if (existing && next.size === existing.size) return map; + return new Map(map).set(sessionId, next); + }); } /** @@ -70,22 +86,31 @@ export class McpAppStateService { * growing, server-healed prefix of the arguments object. */ recordPartialInput( + sessionId: string, toolUseId: string, args: Record, ): void { - const next = new Map(this.partialInputByToolUseId()); - next.set(toolUseId, args); - this.partialInputByToolUseId.set(next); + this.partialInputBySession.update(map => + writeEntry(map, sessionId, toolUseId, args), + ); } /** Latest streamed partial tool input for a tool invocation, or undefined. */ - getPartialInput(toolUseId: string): Record | undefined { - return this.partialInputByToolUseId().get(toolUseId); + getPartialInput( + sessionId: string | null, + toolUseId: string, + ): Record | undefined { + if (!sessionId) return undefined; + return this.partialInputBySession().get(sessionId)?.get(toolUseId); } /** The UI resource for a tool invocation, or undefined. */ - get(toolUseId: string): UiResourceEvent | undefined { - return this.byToolUseId().get(toolUseId); + get( + sessionId: string | null, + toolUseId: string, + ): UiResourceEvent | undefined { + if (!sessionId) return undefined; + return this.bySession().get(sessionId)?.get(toolUseId); } /** @@ -93,13 +118,26 @@ export class McpAppStateService { * so a `computed()` that calls it stays reactive to the `ui_resource` * event arriving after the tool-use block first renders. */ - has(toolUseId: string): boolean { - return this.byToolUseId().has(toolUseId); + has(sessionId: string | null, toolUseId: string): boolean { + if (!sessionId) return false; + return this.bySession().get(sessionId)?.has(toolUseId) ?? false; } - /** Drop all resources — called on conversation change (teardown). */ + /** Drop everything — full teardown (e.g. sign-out). */ reset(): void { - this.byToolUseId.set(new Map()); - this.partialInputByToolUseId.set(new Map()); + this.bySession.set(new Map()); + this.partialInputBySession.set(new Map()); } } + +/** Immutably set `sessionId → toolUseId → value`, last write wins. */ +function writeEntry( + map: ReadonlyMap>, + sessionId: string, + toolUseId: string, + value: T, +): ReadonlyMap> { + const next = new Map(map.get(sessionId) ?? []); + next.set(toolUseId, value); + return new Map(map).set(sessionId, next); +} diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-teardown.service.spec.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-teardown.service.spec.ts new file mode 100644 index 000000000..7adc3e4e6 --- /dev/null +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-teardown.service.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { McpAppTeardownService } from './mcp-app-teardown.service'; +import type { McpAppBridge } from './mcp-app-bridge'; + +/** Only `dispose` is exercised; the registry never touches anything else. */ +function fakeBridge(dispose = vi.fn().mockResolvedValue(undefined)) { + return { dispose } as unknown as McpAppBridge & { + dispose: ReturnType; + }; +} + +describe('McpAppTeardownService', () => { + let svc: McpAppTeardownService; + + beforeEach(() => { + svc = new McpAppTeardownService(); + }); + + it('tears down every live App with the given reason', async () => { + const a = fakeBridge(); + const b = fakeBridge(); + svc.register(a); + svc.register(b); + + await svc.teardownAll('conversation-change'); + + expect(a.dispose).toHaveBeenCalledWith('conversation-change'); + expect(b.dispose).toHaveBeenCalledWith('conversation-change'); + }); + + it('clears the registry so a second navigation re-notifies nothing', async () => { + const a = fakeBridge(); + svc.register(a); + + await svc.teardownAll('first'); + await svc.teardownAll('second'); + + expect(a.dispose).toHaveBeenCalledTimes(1); + expect(svc.liveCount).toBe(0); + }); + + it('deregisters a frame that unmounts on its own', async () => { + const a = fakeBridge(); + const unregister = svc.register(a); + unregister(); + + await svc.teardownAll('conversation-change'); + + expect(a.dispose).not.toHaveBeenCalled(); + expect(svc.liveCount).toBe(0); + }); + + it('one hung App does not strand the others', async () => { + const rejecting = fakeBridge(vi.fn().mockRejectedValue(new Error('gone'))); + const healthy = fakeBridge(); + svc.register(rejecting); + svc.register(healthy); + + // Navigation waits on this; a failed teardown must not reject it. + await expect(svc.teardownAll('conversation-change')).resolves.toBeUndefined(); + expect(healthy.dispose).toHaveBeenCalled(); + }); + + it('resolves immediately when no App is open', async () => { + await expect(svc.teardownAll('conversation-change')).resolves.toBeUndefined(); + expect(svc.liveCount).toBe(0); + }); +}); diff --git a/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-teardown.service.ts b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-teardown.service.ts new file mode 100644 index 000000000..d33883cdb --- /dev/null +++ b/frontend/ai.client/src/app/session/services/mcp-apps/mcp-app-teardown.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import type { McpAppBridge } from './mcp-app-bridge'; + +/** + * Registry of live MCP App bridges, so the host can tell every open App + * that it is about to go away. + * + * SEP-1865 requires the host to send `ui/resource-teardown` before tearing + * a resource down "for any reason", and to wait for the response where it + * can, so the App gets a chance to flush state to its own server. That + * matters here more than it looks: the App's state is the App's own + * responsibility to persist — the host deliberately isn't the store of + * record — so a teardown the App never hears about is state nobody saves. + * + * The frame component already disposes its bridge on destroy, but by then + * Angular is removing the iframe in the same tick and the App's window is + * gone before it can react. This registry exists so teardown can be fired + * at *navigation intent* instead — while the iframes are still alive and + * their Views can still run code. The App's save call is proxied over HTTP + * from the host page, so once its message reaches us the request outlives + * the iframe. + */ +@Injectable({ providedIn: 'root' }) +export class McpAppTeardownService { + private readonly live = new Set(); + + /** Track a bridge; returns the deregistration callback. */ + register(bridge: McpAppBridge): () => void { + this.live.add(bridge); + return () => this.live.delete(bridge); + } + + /** Number of live App bridges (specs + callers that want to skip work). */ + get liveCount(): number { + return this.live.size; + } + + /** + * Notify every live App that it is being torn down, and resolve once they + * have all acked or their grace windows have expired. + * + * Callers that can await (a route guard) get the spec's "wait for a + * response" behavior. Callers that can't — a synchronous navigation + * effect — should still call this WITHOUT awaiting: the notification goes + * out while the iframes are alive, and each bridge keeps listening + * through its own grace window, which is the part that actually rescues + * the App's flush. + */ + teardownAll(reason: string): Promise { + if (!this.live.size) return Promise.resolve(); + const bridges = [...this.live]; + this.live.clear(); + return Promise.all( + bridges.map((bridge) => bridge.dispose(reason).catch(() => undefined)), + ).then(() => undefined); + } +} diff --git a/frontend/ai.client/src/app/session/services/session/message-map.service.ts b/frontend/ai.client/src/app/session/services/session/message-map.service.ts index e9f767394..838adb8ed 100644 --- a/frontend/ai.client/src/app/session/services/session/message-map.service.ts +++ b/frontend/ai.client/src/app/session/services/session/message-map.service.ts @@ -436,9 +436,15 @@ export class MessageMapService { // resources the backend replays on this response. The inline // `ui_resource` event never re-streams, so without this the // `mcp-app-frame` falls back to a plain tool card after a refresh. - // session.page resets McpAppStateService before this load, so the - // non-clobbering seed lands cleanly. - this.mcpAppState.seedFromHydration(messagesResponse.uiResources ?? []); + // Only reached on a real fetch — `loadMessagesForSession` skips this + // whole path once a conversation's messages are cached, which is why + // McpAppStateService retains per conversation instead of resetting on + // navigation. The seed is non-clobbering, so it can't undo a live + // `recordLive` entry for the same invocation. + this.mcpAppState.seedFromHydration( + sessionId, + messagesResponse.uiResources ?? [], + ); } finally { if (showLoading) { this._isLoadingSession.set(null); diff --git a/frontend/ai.client/src/app/session/session.page.ts b/frontend/ai.client/src/app/session/session.page.ts index 17319f4bb..470767b7c 100644 --- a/frontend/ai.client/src/app/session/session.page.ts +++ b/frontend/ai.client/src/app/session/session.page.ts @@ -20,9 +20,9 @@ import { CompactionSummaryService } from './services/chat/compaction-summary.ser import { SteeringService } from './services/chat/steering.service'; import { ArtifactStateService } from './services/artifacts/artifact-state.service'; import { ArtifactHttpService } from './services/artifacts/artifact-http.service'; -import { McpAppStateService } from './services/mcp-apps/mcp-app-state.service'; import { McpAppCardStateService } from './services/mcp-apps/mcp-app-card-state.service'; import { McpAppCardHttpService } from './services/mcp-apps/mcp-app-card-http.service'; +import { McpAppTeardownService } from './services/mcp-apps/mcp-app-teardown.service'; import { McpAppConsentService } from './services/mcp-apps/mcp-app-consent.service'; import { Dialog } from '@angular/cdk/dialog'; import { AssistantService } from '../assistants/services/assistant.service'; @@ -62,9 +62,9 @@ export class ConversationPage implements OnDestroy { private compactionSummary = inject(CompactionSummaryService); private steering = inject(SteeringService); private artifactState = inject(ArtifactStateService); - private mcpAppState = inject(McpAppStateService); private mcpAppCardState = inject(McpAppCardStateService); private mcpAppCardHttp = inject(McpAppCardHttpService); + private mcpAppTeardown = inject(McpAppTeardownService); private mcpAppConsent = inject(McpAppConsentService); private oauthConsent = inject(OAuthConsentService); private artifactHttp = inject(ArtifactHttpService); @@ -419,12 +419,27 @@ export class ConversationPage implements OnDestroy { // from the app-api list endpoint below. this.artifactState.reset(); - // MCP App frames persist for the conversation's lifetime per the - // scoping doc; teardown is on conversation change. Clear before the - // next load — loadMessagesForSession re-seeds from the persisted - // `uiResources` sidecar on the messages response so frames survive a - // refresh (the inline `ui_resource` event itself only arrives live). - this.mcpAppState.reset(); + // Tell every open MCP App it is going away BEFORE its iframe + // unmounts with the message list (SEP-1865: the host sends + // `ui/resource-teardown` for any teardown, so the App can flush state + // to its own server — the host is deliberately not the store of + // record for App state). Fired, not awaited: this effect is + // synchronous, and the value is in the timing, not the wait — the + // notification goes out while the Views are still alive, and each + // bridge keeps listening through its grace window so a save call the + // App makes in response is still proxied. A truly blocking wait would + // need a route guard; see the follow-up note in the teardown service. + void this.mcpAppTeardown.teardownAll('conversation-change'); + + // MCP App UI resources are deliberately NOT cleared here. They are + // held per conversation in McpAppStateService and retained for the + // SPA session, mirroring the message cache: the only server-side + // replay is the `uiResources` sidecar on `GET /messages`, and + // loadMessagesForSession skips that request once a conversation's + // messages are already in memory — so a reset on navigation had no + // way back and dropped every frame to a plain tool card until a hard + // refresh. The iframes themselves still tear down with the message + // list components on conversation change. // Option A (PR #6): app-initiated tool cards DO re-hydrate (the // broker is in-memory). Any open consent prompt for the prior diff --git a/infrastructure/bootstrap-assets/kb-migration/Dockerfile b/infrastructure/bootstrap-assets/kb-migration/Dockerfile index c03ba3963..460adf41f 100644 --- a/infrastructure/bootstrap-assets/kb-migration/Dockerfile +++ b/infrastructure/bootstrap-assets/kb-migration/Dockerfile @@ -17,12 +17,13 @@ # generated file, an unpinned base tag — and the next platform deploy # silently reverts all four functions to this no-op stub. # -# All FOUR functions use this ONE build context with different +# All FIVE functions use this ONE build context with different # ImageConfig.Command overrides, so the stub modules must live at the # SAME dotted paths as the real image's handlers: # apis.app_api.kb_migration.dispatcher.lambda_handler # apis.app_api.kb_migration.worker.lambda_handler # apis.app_api.kb_migration.reconciler.lambda_handler +# apis.app_api.kb_migration.document_reconciler.lambda_handler # apis.app_api.kb_migration.ingestion_consumer.lambda_handler # # DO NOT add functionality here. @@ -37,6 +38,7 @@ FROM public.ecr.aws/lambda/python:3.12@sha256:745b0eb8a9787e9c4bfd4fc4cae942399a COPY dispatcher.py ${LAMBDA_TASK_ROOT}/apis/app_api/kb_migration/dispatcher.py COPY worker.py ${LAMBDA_TASK_ROOT}/apis/app_api/kb_migration/worker.py COPY reconciler.py ${LAMBDA_TASK_ROOT}/apis/app_api/kb_migration/reconciler.py +COPY document_reconciler.py ${LAMBDA_TASK_ROOT}/apis/app_api/kb_migration/document_reconciler.py COPY ingestion_consumer.py ${LAMBDA_TASK_ROOT}/apis/app_api/kb_migration/ingestion_consumer.py CMD ["apis.app_api.kb_migration.dispatcher.lambda_handler"] diff --git a/infrastructure/bootstrap-assets/kb-migration/document_reconciler.py b/infrastructure/bootstrap-assets/kb-migration/document_reconciler.py new file mode 100644 index 000000000..7c4e37db1 --- /dev/null +++ b/infrastructure/bootstrap-assets/kb-migration/document_reconciler.py @@ -0,0 +1,23 @@ +# Bootstrap handler for the Managed_KB nightly document reconciler Lambda. +# +# See the sibling reconciler.py and the Dockerfile in this directory. +# A nightly tick that lands here reports nothing and writes nothing. +# That is the safe direction to fail: the real document reconciler only +# ever corrects a DOC# row after confirming Bedrock's own state, and it +# ships disarmed, so a run this stub missed cannot mislabel a document +# once the real image arrives. +# +# DO NOT add functionality here. + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler(event: Any, context: Any) -> dict: + logger.info("kb-migration document reconciler bootstrap stub invoked; real image not yet deployed") + return {"statusCode": 200, "body": "bootstrap"} diff --git a/infrastructure/cdk.context.json b/infrastructure/cdk.context.json index 28eb85dd8..1fa8f03b3 100644 --- a/infrastructure/cdk.context.json +++ b/infrastructure/cdk.context.json @@ -20,8 +20,8 @@ "cloudFrontPriceClass": "PriceClass_100" }, "appApi": { - "cpu": 512, - "memory": 1024, + "cpu": 1024, + "memory": 2048, "desiredCount": 2, "maxCapacity": 10 }, diff --git a/infrastructure/lib/config.ts b/infrastructure/lib/config.ts index 44fd6745e..ad46c4dab 100644 --- a/infrastructure/lib/config.ts +++ b/infrastructure/lib/config.ts @@ -217,6 +217,14 @@ export interface ManagedKbConfig { migrationEnabled: boolean; /** The Reconciler deletes rather than only reporting. Default false. */ reconcilerArmed: boolean; + /** + * The dead-letter document reconciler CORRECTS stranded DOC# rows — + * marking retrievable-but-stranded documents complete and re-ingesting + * missing ones — rather than only reporting them. Same inverted + * convention as `reconcilerArmed`: deployed and running from day one but + * disarmed, so its judgement is auditable before it writes. Default false. + */ + docReconcilerArmed: boolean; /** Per-owner Byte_Cap, standard role tier. Default 100 MB. */ perOwnerDefaultBytes: number; /** Per-owner Byte_Cap, elevated (admin-granted) role tier. Default 1 GB. */ @@ -474,6 +482,35 @@ export const OBSERVABILITY_DEFAULT_PROMPT_CACHE_WASTED_USD_THRESHOLD = 1; * cannot see a single conversation re-writing its prefix every turn. */ export const OBSERVABILITY_DEFAULT_PROMPT_CACHE_SESSION_WASTED_USD_THRESHOLD = 5; +/** + * Percent of a model's tokens-per-minute quota at which to alarm. + * + * Below 100 on purpose: a Bedrock quota increase takes lead time, so the alarm + * is only useful if it fires while there is still time to request one. + */ +export const OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTA_PERCENT = 75; + +/** + * Per-model tokens-per-minute quotas, keyed by the `ModelId` dimension value + * Bedrock publishes on `EstimatedTPMQuotaUsage`. + * + * Empty by default, and that is the only honest default. TPM quotas are per + * model *and* per account, most are adjustable, and they differ by two orders + * of magnitude between models in the same account — 40,000,000 for one + * inference profile next to 200,000 for another. Any shipped number would be + * wrong for every fork and would go stale silently the first time somebody + * requested an increase. + * + * With no entries, no quota alarm is created at all. That is deliberate: the + * backstop is `bedrock-invocation-throttles`, which fires on real refusals and + * needs no quota configured. Populating this buys the *leading* indicator. + * + * Read the values actually applied to an account with: + * aws service-quotas list-service-quotas --service-code bedrock \ + * --query "Quotas[?contains(QuotaName,'tokens per minute')]" + */ +export const OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTAS: { [modelId: string]: number } = {}; + /** * Observability configuration. * @@ -500,6 +537,15 @@ export interface ObservabilityConfig { promptCacheWastedUsdThreshold: number; promptCacheSessionWastedUsdThreshold: number; + /** Percent of a model's TPM quota at which its usage alarm fires. */ + bedrockTpmQuotaPercent: number; + /** + * Per-model TPM quotas keyed by `ModelId`. Empty creates no quota alarms at + * all; see OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTAS for why there is no + * shippable default. + */ + bedrockTpmQuotas: { [modelId: string]: number }; + xraySamplingRate: number; xraySamplingReservoir: number; xrayInsightsNotifications: boolean; @@ -621,10 +667,32 @@ export function loadConfig(scope: cdk.App): AppConfig { additionalCorsOrigins: process.env.CDK_FRONTEND_CORS_ORIGINS || scope.node.tryGetContext('frontend')?.additionalCorsOrigins, }, appApi: { - cpu: parseIntEnv(process.env.CDK_APP_API_CPU) || scope.node.tryGetContext('appApi')?.cpu, - memory: parseIntEnv(process.env.CDK_APP_API_MEMORY) || scope.node.tryGetContext('appApi')?.memory, - desiredCount: parseIntEnv(process.env.CDK_APP_API_DESIRED_COUNT) ?? scope.node.tryGetContext('appApi')?.desiredCount, - maxCapacity: parseIntEnv(process.env.CDK_APP_API_MAX_CAPACITY) || scope.node.tryGetContext('appApi')?.maxCapacity, + // Precedence for every sizing knob: env var > FLAT dotted context > + // nested context object. + // + // The flat form is not optional. `--context appApi.cpu=2048` — which is + // exactly what scripts/common/load-env.sh emits — sets the flat key + // context['appApi.cpu']; it does NOT build a nested { appApi: { cpu } }. + // Reading only the nested form accepted the operator's flag and silently + // ignored it, so every --context sizing override was dead. Same failure + // mode as the observability tunables (see OBSERVABILITY_DEFAULT_* notes). + // + // `??` rather than `||` throughout: parseIntEnv already maps '' and + // unparseable input to undefined, so `??` is safe, and it stops a + // legitimate 0 (e.g. desiredCount: 0 to park an environment) from being + // swallowed as falsy. + cpu: parseIntEnv(process.env.CDK_APP_API_CPU) + ?? parseIntEnv(scope.node.tryGetContext('appApi.cpu')) + ?? scope.node.tryGetContext('appApi')?.cpu, + memory: parseIntEnv(process.env.CDK_APP_API_MEMORY) + ?? parseIntEnv(scope.node.tryGetContext('appApi.memory')) + ?? scope.node.tryGetContext('appApi')?.memory, + desiredCount: parseIntEnv(process.env.CDK_APP_API_DESIRED_COUNT) + ?? parseIntEnv(scope.node.tryGetContext('appApi.desiredCount')) + ?? scope.node.tryGetContext('appApi')?.desiredCount, + maxCapacity: parseIntEnv(process.env.CDK_APP_API_MAX_CAPACITY) + ?? parseIntEnv(scope.node.tryGetContext('appApi.maxCapacity')) + ?? scope.node.tryGetContext('appApi')?.maxCapacity, additionalCorsOrigins: process.env.CDK_APP_API_CORS_ORIGINS || scope.node.tryGetContext('appApi')?.additionalCorsOrigins, }, inferenceApi: { @@ -694,6 +762,11 @@ export function loadConfig(scope: cdk.App): AppConfig { ?? parseBooleanEnv(scope.node.tryGetContext('managedKb.reconcilerArmed')) ?? scope.node.tryGetContext('managedKb')?.reconcilerArmed ?? false, + docReconcilerArmed: + parseBooleanEnv(process.env.CDK_MANAGED_KB_DOC_RECONCILER_ARMED) + ?? parseBooleanEnv(scope.node.tryGetContext('managedKb.docReconcilerArmed')) + ?? scope.node.tryGetContext('managedKb')?.docReconcilerArmed + ?? false, // Byte caps in BYTES so no consumer has to guess a unit. The // standard tier is deliberately below the 1 GB user-files // precedent (Requirement 12.2). @@ -954,6 +1027,18 @@ export function loadConfig(scope: cdk.App): AppConfig { ?? parseFloatEnv(scope.node.tryGetContext('observability.promptCacheSessionWastedUsdThreshold')) ?? scope.node.tryGetContext('observability')?.promptCacheSessionWastedUsdThreshold ?? OBSERVABILITY_DEFAULT_PROMPT_CACHE_SESSION_WASTED_USD_THRESHOLD, + bedrockTpmQuotaPercent: + parseIntEnv(process.env.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT) + ?? parseIntEnv(scope.node.tryGetContext('observability.bedrockTpmQuotaPercent')) + ?? scope.node.tryGetContext('observability')?.bedrockTpmQuotaPercent + ?? OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTA_PERCENT, + // A map, not a scalar. Reaches us as an object from nested context, or as + // a JSON / `k=v,k=v` string from the env var and flat context key. + bedrockTpmQuotas: + parseModelQuotaMapEnv(process.env.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS) + ?? parseModelQuotaMapEnv(scope.node.tryGetContext('observability.bedrockTpmQuotas')) + ?? scope.node.tryGetContext('observability')?.bedrockTpmQuotas + ?? OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTAS, // parseFloatEnv: parseIntEnv turns 0.05 into 0, disabling sampling. xraySamplingRate: parseFloatEnv(process.env.CDK_OBSERVABILITY_XRAY_SAMPLING_RATE) @@ -1128,6 +1213,73 @@ export function parseJsonRecordEnv( } } +/** + * Parse a per-model quota map: `ModelId` -> tokens-per-minute quota. + * + * Accepts three input shapes, because this value reaches config by three routes + * and only one of them can carry double quotes safely: + * + * 1. a real object — from a nested `observability` block in cdk.context.json + * 2. a JSON string — `{"global.anthropic.claude-sonnet-5":40000000}` + * 3. a compact `k=v,k=v` string — `global.anthropic.claude-sonnet-5=40000000` + * + * Form 3 exists because `deploy.sh` runs `eval npx cdk synth ${CDK_CONTEXT_PARAMS}`. + * Under eval the shell removes quote characters, so a JSON value passed through a + * `--context` flag arrives as `{model:40000000}` — no longer valid JSON. Form 3 + * contains no quotes at all and therefore survives eval unchanged, which makes it + * the form to use for CI variables. load-env.sh single-quotes the value as well, + * so form 2 also survives, but form 3 needs nothing to go right. + * + * Malformed input returns undefined so nullish coalescing falls through to the + * default; individual bad entries are filtered rather than throwing. Non-finite + * values are rejected — a NaN threshold is accepted by CloudFormation and can + * never be crossed by any metric, which is a silent dead alarm. + */ +export function parseModelQuotaMapEnv( + value: unknown, +): { [modelId: string]: number } | undefined { + let parsed: unknown = value; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed === '') { + return undefined; + } + try { + parsed = JSON.parse(trimmed); + } catch { + // Not JSON — try the eval-safe `k=v,k=v` form before giving up. + const pairs: { [modelId: string]: number } = {}; + let sawOne = false; + for (const part of trimmed.split(',')) { + const eq = part.lastIndexOf('='); + if (eq <= 0) continue; + const key = part.slice(0, eq).trim(); + const num = Number(part.slice(eq + 1).trim()); + if (key !== '' && Number.isFinite(num)) { + pairs[key] = num; + sawOne = true; + } + } + return sawOne ? pairs : undefined; + } + } else if (value === undefined || value === null) { + return undefined; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return undefined; + } + + const result: { [modelId: string]: number } = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof k === 'string' && typeof v === 'number' && Number.isFinite(v)) { + result[k] = v; + } + } + return result; +} + /** * Validate AWS account ID format * @param account The AWS account ID to validate diff --git a/infrastructure/lib/constructs/managed-kb/kb-migration-construct.ts b/infrastructure/lib/constructs/managed-kb/kb-migration-construct.ts index 0a0b6ad6c..ead154096 100644 --- a/infrastructure/lib/constructs/managed-kb/kb-migration-construct.ts +++ b/infrastructure/lib/constructs/managed-kb/kb-migration-construct.ts @@ -136,15 +136,19 @@ export interface KbMigrationConstructProps { * - reconciler — daily tag-filtered `ListKnowledgeBases` joined * against KB_Records (Req 14). Ships DISARMED: it reports intended * deletions and deletes nothing. + * - document reconciler — nightly join of Bedrock's per-document view + * against non-terminal DOC# rows (task 16.5, §5.37). The second + * writer the ingestion consumer's dead-letter path never had. Ships + * DISARMED: it reports intended corrections and writes nothing. * - ingestion consumer — durable, retryable replacement for the * in-process `asyncio.ensure_future` orchestration, routing each * uploaded document to the legacy pipeline or to Direct_Ingestion * according to its knowledge base's engine (Req 10). * - * ONE IMAGE, FOUR FUNCTIONS. Every function points + * ONE IMAGE, FIVE FUNCTIONS. Every function points * `fromImageAsset` at the SAME byte-stable `bootstrap-assets/kb-migration/` * directory, so CDK emits a single image asset and the platform deploy - * pushes one image rather than four. The per-function difference is the + * pushes one image rather than five. The per-function difference is the * `cmd` override, which lands in `ImageConfig.Command` — function * *configuration*, not code. That distinction is what makes the * platform-as-bootstrap pattern work here: the backend workflow's @@ -187,17 +191,26 @@ export interface KbMigrationConstructProps { * /{prefix}/kb-migration/dispatcher-function-name * /{prefix}/kb-migration/worker-function-name * /{prefix}/kb-migration/reconciler-function-name + * /{prefix}/kb-migration/document-reconciler-function-name * /{prefix}/kb-migration/ingestion-consumer-function-name */ export class KbMigrationConstruct extends Construct { public readonly dispatcherLambda: lambda.DockerImageFunction; public readonly workerLambda: lambda.DockerImageFunction; public readonly reconcilerLambda: lambda.DockerImageFunction; + /** + * Dead-letter document reconciler (task 16.5). Drives DOC# rows stuck + * non-terminal past a grace window to their true state — the second + * writer the ingestion consumer's dead-letter path never had. + */ + public readonly documentReconcilerLambda: lambda.DockerImageFunction; public readonly ingestionConsumerLambda: lambda.DockerImageFunction; /** Async-invocation dead-letter queue for the ingestion consumer (Req 10.1). */ public readonly ingestionConsumerDlq: sqs.Queue; public readonly dispatcherScheduleRule: events.Rule; public readonly reconcilerScheduleRule: events.Rule; + /** Nightly document-reconciler tick (task 16.5). */ + public readonly documentReconcilerScheduleRule: events.Rule; /** Documents-bucket `Object Created` rule feeding the ingestion consumer. */ public readonly documentsEventRule: events.Rule; @@ -228,6 +241,7 @@ export class KbMigrationConstruct extends Construct { MANAGED_KB_NEW_DEFAULT: managedKb.newDefault ? 'true' : 'false', MANAGED_KB_MIGRATION_ENABLED: managedKb.migrationEnabled ? 'true' : 'false', MANAGED_KB_RECONCILER_ARMED: managedKb.reconcilerArmed ? 'true' : 'false', + MANAGED_KB_DOC_RECONCILER_ARMED: managedKb.docReconcilerArmed ? 'true' : 'false', MANAGED_KB_PER_OWNER_DEFAULT_BYTES: String(managedKb.perOwnerDefaultBytes), MANAGED_KB_PER_OWNER_ELEVATED_BYTES: String(managedKb.perOwnerElevatedBytes), MANAGED_KB_PER_KB_CEILING_BYTES: String(managedKb.perKnowledgeBaseCeilingBytes), @@ -325,6 +339,41 @@ export class KbMigrationConstruct extends Construct { 'Managed_KB daily reconciler - joins tag-filtered ListKnowledgeBases against KB_Records (report-only until armed)', }); + // ── Document reconciler (task 16.5, HANDOFF §5.37) ── + // + // The ingestion consumer is the only writer of DOC# status, and Lambda's + // async retry caps at 2, so a dead-lettered ingestion event strands a + // document non-terminal even when Bedrock finished indexing it — and the + // retrieval filter serves only `complete`, so its content is in the + // knowledge base and invisible to every query. This is the missing second + // writer: nightly, it drives stranded-but-retrievable rows to `complete`, + // FAILED rows to `failed`, and re-ingests NOT_FOUND rows from S3. + // + // Ships DISARMED (MANAGED_KB_DOC_RECONCILER_ARMED). Same inverted + // convention as the KB reconciler: deployed and running from day one but + // report-only, so its judgement can be audited before it corrects records. + const documentReconcilerLogGroup = new logs.LogGroup(this, 'KbDocumentReconcilerLogGroup', { + retention: logRetentionFor(config), + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + this.documentReconcilerLambda = new lambda.DockerImageFunction(this, 'KbDocumentReconcilerLambda', { + code: lambda.DockerImageCode.fromImageAsset(bootstrapDir, { + cmd: ['apis.app_api.kb_migration.document_reconciler.lambda_handler'], + }), + architecture: lambda.Architecture.ARM_64, + // Scans managed KB_Records, queries each one's DOC# rows, and probes + // Bedrock (GetKnowledgeBaseDocuments + a filtered Retrieve) per stranded + // document, re-ingesting the truly-missing. A per-run action limit bounds + // the corrective work; 15 min gives headroom for a probe-heavy pass. + timeout: cdk.Duration.minutes(15), + memorySize: 512, + logGroup: documentReconcilerLogGroup, + environment: sharedEnvironment, + description: + 'Managed_KB dead-letter document reconciler - drives stranded DOC# rows to their true state (report-only until armed)', + }); + // ── Ingestion consumer + its dead-letter queue (task 2.2) ── // // The DLQ is the "durable retry anchor" half of Requirement 10.1/10.7. @@ -377,6 +426,7 @@ export class KbMigrationConstruct extends Construct { this.dispatcherLambda, this.workerLambda, this.reconcilerLambda, + this.documentReconcilerLambda, this.ingestionConsumerLambda, ]; @@ -394,6 +444,10 @@ export class KbMigrationConstruct extends Construct { // size). Read-only: nothing here writes user documents. documentsBucket.grantRead(this.workerLambda); documentsBucket.grantRead(this.ingestionConsumerLambda); + // The document reconciler re-ingests NOT_FOUND documents from their + // existing S3 keys — the same re-ingest path as the ingestion consumer, + // so it needs the same read grant. Read-only: it never writes documents. + documentsBucket.grantRead(this.documentReconcilerLambda); this.workerLambda.grantInvoke(this.dispatcherLambda); @@ -424,6 +478,16 @@ export class KbMigrationConstruct extends Construct { managedKbRole.grantRetrieval(this.workerLambda.role!); managedKbRole.grantRetrieval(this.ingestionConsumerLambda.role!); + // Document reconciler (task 16.5). It probes Bedrock's document view + // (GetKnowledgeBaseDocuments — under grantDirectIngestion), confirms + // retrievability (bedrock:Retrieve — grantRetrieval), and re-ingests + // NOT_FOUND documents (IngestKnowledgeBaseDocuments — grantDirectIngestion). + // Deliberately NOT grantProvisioning: it reads KB_Records from DynamoDB, not + // ListKnowledgeBases, and never creates or deletes a knowledge base — a + // strictly narrower footprint than the KB reconciler beside it. + managedKbRole.grantDirectIngestion(this.documentReconcilerLambda.role!); + managedKbRole.grantRetrieval(this.documentReconcilerLambda.role!); + // The dispatcher receives none of the three grants above, each of // which carries its own namespace-conditioned PutMetricData // statement, so it needs one of its own to emit dispatch metrics. @@ -493,6 +557,24 @@ export class KbMigrationConstruct extends Construct { }); this.reconcilerScheduleRule.addTarget(new targets.LambdaFunction(this.reconcilerLambda)); + // Nightly document reconciliation (task 16.5). A fixed off-peak hour + // (09:00 UTC ≈ 02:00-03:00 America/Denver) rather than rate(1 day), so + // "nightly" means night rather than "24h after each deploy". ENABLED + // regardless of the flags, for the same reason as the KB reconciler above: + // it ships report-only (MANAGED_KB_DOC_RECONCILER_ARMED off), report-only is + // read-only, and the audit period only happens if the schedule runs. A + // dead-lettered document is rare, so once-a-day recovery latency is + // acceptable; tighten to hourly here if dead-letters ever prove common. + this.documentReconcilerScheduleRule = new events.Rule(this, 'KbDocumentReconcilerSchedule', { + schedule: events.Schedule.cron({ minute: '0', hour: '9' }), + enabled: true, + description: + 'Managed_KB nightly document reconciler tick — runs report-only until MANAGED_KB_DOC_RECONCILER_ARMED is set', + }); + this.documentReconcilerScheduleRule.addTarget( + new targets.LambdaFunction(this.documentReconcilerLambda), + ); + // ── Documents-bucket ObjectCreated trigger (task 2.2) ── // // WHY EVENTBRIDGE AND NOT A SECOND BUCKET NOTIFICATION. @@ -660,6 +742,7 @@ export class KbMigrationConstruct extends Construct { ['DispatcherFunctionNameParameter', 'dispatcher', this.dispatcherLambda], ['WorkerFunctionNameParameter', 'worker', this.workerLambda], ['ReconcilerFunctionNameParameter', 'reconciler', this.reconcilerLambda], + ['DocumentReconcilerFunctionNameParameter', 'document-reconciler', this.documentReconcilerLambda], ['IngestionConsumerFunctionNameParameter', 'ingestion-consumer', this.ingestionConsumerLambda], ]; for (const [logicalId, slug, fn] of functionNameParameters) { diff --git a/infrastructure/lib/constructs/observability/ai-path-alarms-construct.ts b/infrastructure/lib/constructs/observability/ai-path-alarms-construct.ts index 78bbeb73d..43854e89f 100644 --- a/infrastructure/lib/constructs/observability/ai-path-alarms-construct.ts +++ b/infrastructure/lib/constructs/observability/ai-path-alarms-construct.ts @@ -11,6 +11,28 @@ const AGENTCORE_NAMESPACE = 'AWS/Bedrock-AgentCore'; /** The namespace bedrock-runtime inference publishes to. */ const BEDROCK_NAMESPACE = 'AWS/Bedrock'; +/** + * Alarm-name-safe label for a Bedrock model id. + * + * `ModelId` arrives in three spellings for the same underlying model: a bare id + * (`amazon.titan-embed-text-v2:0`), an inference-profile id + * (`global.anthropic.claude-sonnet-5`), and a full foundation-model ARN + * (`arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-3-haiku-...`). + * The ARN form is reduced to its last path segment, so an ARN-keyed entry and a + * bare-id entry for the same model produce the same readable label rather than + * one unreadable one. Quotas differ per inference profile, so `us.` and + * `global.` variants deliberately remain distinct. + */ +function modelSlug(modelId: string): string { + const bare = modelId.includes('/') + ? modelId.slice(modelId.lastIndexOf('/') + 1) + : modelId; + return bare + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + export interface AiPathAlarmsConstructProps { config: AppConfig; /** AgentCore Memory ARN. The `Resource` dimension value is the full ARN. */ @@ -124,19 +146,48 @@ export class AiPathAlarmsConstruct extends Construct { treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); - // The only leading indicator here: quota usage climbs before throttling. - alarms.alarm('BedrockQuotaUsageAlarm', { - name: 'bedrock-tpm-quota-usage', - alarmDescription: - 'Estimated Bedrock tokens-per-minute quota usage is high. This is the leading ' - + 'indicator for bedrock-invocation-throttles — acting on it means requesting a ' - + 'quota increase before users see failures rather than after.', - metric: bedrockMetric('EstimatedTPMQuotaUsage', 'Maximum'), - threshold: 80, - evaluationPeriods: 3, - comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, - }); + // Quota usage is the only leading indicator in this construct: it climbs + // before throttling starts. It has to be measured PER MODEL, because a TPM + // quota is per model and per inference profile. The account-wide roll-up has + // no single denominator to be a percentage of — summing a 40,000,000-quota + // profile with a 200,000-quota one produces a number comparable to nothing, + // and it hides the model closest to its own ceiling, which is usually the + // one with the smallest quota rather than the most traffic. + // + // Statistic stays Maximum, not Average: the quota is per *minute*, the + // period is 5 minutes, and the underlying data is 1-minute, so Maximum reads + // the peak minute in each window. Averaging would dilute a real spike below + // the threshold. + // + // No configured quotas means no alarms here at all; the backstop is + // bedrock-invocation-throttles. See OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTAS. + const quotaPercent = config.observability.bedrockTpmQuotaPercent; + for (const [modelId, tpmQuota] of Object.entries(config.observability.bedrockTpmQuotas)) { + const slug = modelSlug(modelId); + + alarms.alarm(`BedrockQuotaUsageAlarm-${slug}`, { + name: `bedrock-tpm-quota-usage-${slug}`, + alarmDescription: + `Estimated TPM quota usage for ${modelId} reached ${quotaPercent}% of its ` + + `configured ${tpmQuota} TPM quota. FIRST: confirm ${tpmQuota} is still the live ` + + 'quota — it is configured by hand and nothing checks it automatically ' + + '(aws service-quotas list-service-quotas --service-code bedrock). If it is ' + + 'current, this is the leading indicator for bedrock-invocation-throttles: ' + + 'request an increase now, because increases take lead time. The metric excludes ' + + 'quota Bedrock reserves up front from max_tokens, so real pressure can be higher.', + metric: new cloudwatch.Metric({ + namespace: BEDROCK_NAMESPACE, + metricName: 'EstimatedTPMQuotaUsage', + dimensionsMap: { ModelId: modelId }, + statistic: 'Maximum', + period: ALARM_PERIOD, + }), + threshold: Math.floor((tpmQuota * quotaPercent) / 100), + evaluationPeriods: 3, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); + } // ============================================================ // AgentCore Memory diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index 2d0c39c17..7ef402558 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.19.1", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.19.1", + "version": "1.20.0", "dependencies": { "aws-cdk-lib": "2.265.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index 7899cf9bb..2f0780436 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.19.1", + "version": "1.20.0", "bin": { "infrastructure": "bin/infrastructure.js" }, diff --git a/infrastructure/test/app-api-sizing-config.test.ts b/infrastructure/test/app-api-sizing-config.test.ts new file mode 100644 index 000000000..4bc99cc27 --- /dev/null +++ b/infrastructure/test/app-api-sizing-config.test.ts @@ -0,0 +1,75 @@ +// Guards the appApi sizing precedence chain: env var > flat dotted context > nested +// context object. The flat-key form is the trap — `--context appApi.cpu=2048` +// (what scripts/common/load-env.sh emits) sets context['appApi.cpu'], NOT a nested +// object, so reading only the nested form accepts the operator's flag and ignores it. +// Case 4 covers the GitHub-Actions case where an unset `vars.*` arrives as an +// empty string and must fall through to the committed default rather than +// becoming 0 or NaN. +import * as cdk from 'aws-cdk-lib'; +import { loadConfig } from '../lib/config'; + +const BASE: Record = { + projectPrefix: 'test-project', + awsRegion: 'us-west-2', + awsAccount: '123456789012', + vpcCidr: '10.0.0.0/16', + production: false, + retainDataOnDelete: false, + frontend: { cloudFrontPriceClass: 'PriceClass_100' }, + inferenceApi: {}, + fineTuning: {}, + artifacts: { retentionDays: 90, extraFrameAncestors: [] }, + mcpSandbox: { extraFrameAncestors: [] }, + ragIngestion: { + additionalCorsOrigins: '', + lambdaMemorySize: 10240, + lambdaTimeout: 900, + embeddingModel: 'amazon.titan-embed-text-v2', + vectorDimension: 1024, + vectorDistanceMetric: 'cosine', + }, +}; + +function mk(ctx: Record) { + const app = new cdk.App(); + for (const [k, v] of Object.entries({ ...BASE, ...ctx })) app.node.setContext(k, v); + return loadConfig(app); +} + +describe('appApi sizing precedence', () => { + const saved = { ...process.env }; + afterEach(() => { process.env = { ...saved }; }); + + it('1. nested context object (the committed cdk.context.json default)', () => { + const c = mk({ appApi: { cpu: 1024, memory: 2048, desiredCount: 2, maxCapacity: 10 } }); + expect([c.appApi.cpu, c.appApi.memory, c.appApi.desiredCount]).toEqual([1024, 2048, 2]); + }); + + it('2. FLAT dotted context beats nested (this was silently ignored before)', () => { + const c = mk({ + appApi: { cpu: 1024, memory: 2048, desiredCount: 2, maxCapacity: 10 }, + 'appApi.cpu': '2048', 'appApi.memory': '4096', 'appApi.desiredCount': '3', + }); + expect([c.appApi.cpu, c.appApi.memory, c.appApi.desiredCount]).toEqual([2048, 4096, 3]); + }); + + it('3. env var beats both', () => { + process.env.CDK_APP_API_CPU = '4096'; + process.env.CDK_APP_API_MEMORY = '8192'; + const c = mk({ + appApi: { cpu: 1024, memory: 2048, desiredCount: 2, maxCapacity: 10 }, + 'appApi.cpu': '2048', 'appApi.memory': '4096', + }); + expect([c.appApi.cpu, c.appApi.memory]).toEqual([4096, 8192]); + }); + + it('4. an UNSET GitHub variable ("") falls through to the committed default', () => { + process.env.CDK_APP_API_CPU = ''; + process.env.CDK_APP_API_MEMORY = ''; + process.env.CDK_APP_API_DESIRED_COUNT = ''; + process.env.CDK_APP_API_MAX_CAPACITY = ''; + const c = mk({ appApi: { cpu: 1024, memory: 2048, desiredCount: 2, maxCapacity: 10 } }); + expect([c.appApi.cpu, c.appApi.memory, c.appApi.desiredCount, c.appApi.maxCapacity]) + .toEqual([1024, 2048, 2, 10]); + }); +}); diff --git a/infrastructure/test/config.test.ts b/infrastructure/test/config.test.ts index 784933c75..08f62c8ce 100644 --- a/infrastructure/test/config.test.ts +++ b/infrastructure/test/config.test.ts @@ -2,6 +2,7 @@ import * as cdk from 'aws-cdk-lib'; import { loadConfig, AppConfig, OBSERVABILITY_DEFAULT_AGENTCORE_ERROR_THRESHOLD, OBSERVABILITY_DEFAULT_ALB_TARGET_5XX_THRESHOLD, + OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTA_PERCENT, OBSERVABILITY_DEFAULT_DYNAMO_THROTTLE_THRESHOLD, OBSERVABILITY_DEFAULT_ECS_CPU_PERCENT, OBSERVABILITY_DEFAULT_ECS_MEMORY_PERCENT, @@ -97,6 +98,8 @@ const OBSERVABILITY_ENV_KEYS = [ 'CDK_OBSERVABILITY_PROMPT_CACHE_AVOIDABLE_MISS_THRESHOLD', 'CDK_OBSERVABILITY_PROMPT_CACHE_WASTED_USD_THRESHOLD', 'CDK_OBSERVABILITY_PROMPT_CACHE_SESSION_WASTED_USD_THRESHOLD', + 'CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT', + 'CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS', ] as const; function clearObservabilityEnv(): void { @@ -1631,6 +1634,12 @@ describe('Observability Configuration', () => { expect(obs.dynamoThrottleThreshold).toBe(OBSERVABILITY_DEFAULT_DYNAMO_THROTTLE_THRESHOLD); expect(obs.ecsCpuPercent).toBe(OBSERVABILITY_DEFAULT_ECS_CPU_PERCENT); expect(obs.ecsMemoryPercent).toBe(OBSERVABILITY_DEFAULT_ECS_MEMORY_PERCENT); + expect(obs.bedrockTpmQuotaPercent).toBe(OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTA_PERCENT); + // Empty on purpose: TPM quotas are per-account and adjustable, so no + // number is shippable. Empty means no quota alarm is created at all, and + // bedrock-invocation-throttles is the backstop. If this ever gains a + // default, every fork silently inherits a wrong threshold. + expect(obs.bedrockTpmQuotas).toEqual({}); }); }); @@ -1646,6 +1655,60 @@ describe('Observability Configuration', () => { expect(loadConfig(app).observability.xraySamplingRate).toBe(0.25); }); + test('CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT reaches config', () => { + process.env.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT = '60'; + expect(loadConfig(app).observability.bedrockTpmQuotaPercent).toBe(60); + }); + + // The quota map is the one non-scalar observability tunable, so it travels + // as a string. Model ids contain dots and colons, which must survive as + // literal key characters. + test('bedrock TPM quota map parses from a JSON env var', () => { + process.env.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS = JSON.stringify({ + 'global.anthropic.claude-sonnet-5': 40000000, + 'us.anthropic.claude-sonnet-4-20250514-v1:0': 200000, + }); + expect(loadConfig(app).observability.bedrockTpmQuotas).toEqual({ + 'global.anthropic.claude-sonnet-5': 40000000, + 'us.anthropic.claude-sonnet-4-20250514-v1:0': 200000, + }); + }); + + // The form CI should use. deploy.sh runs `eval npx cdk synth ${params}` and + // eval strips quote characters, so a quote-free encoding is the only one that + // cannot be corrupted in transit. Note the colon inside the model id, which + // is why the key/value split is on the LAST '=' rather than the first. + test('bedrock TPM quota map parses from the eval-safe k=v form', () => { + process.env.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS = + 'global.anthropic.claude-sonnet-5=40000000,us.anthropic.claude-sonnet-4-20250514-v1:0=200000'; + expect(loadConfig(app).observability.bedrockTpmQuotas).toEqual({ + 'global.anthropic.claude-sonnet-5': 40000000, + 'us.anthropic.claude-sonnet-4-20250514-v1:0': 200000, + }); + }); + + // Regression guard for the bug this shape caused: JSON passed through eval + // the way every other --context value is passed loses its quotes and becomes + // {model:40000000}. That is not JSON and not k=v, so it must fall through to + // the empty default rather than half-parsing into a NaN threshold. + test('eval-mangled JSON does not half-parse into a bad threshold', () => { + process.env.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS = + '{global.anthropic.claude-sonnet-5:40000000}'; + const quotas = loadConfig(app).observability.bedrockTpmQuotas; + expect(Object.values(quotas).every((v) => Number.isFinite(v))).toBe(true); + }); + + // A malformed value must fall through to the default, not abort synth. The + // failure mode to avoid is a half-parsed map producing a NaN threshold, + // which CloudFormation accepts and no metric can ever cross. + test('a malformed quota map falls back to the empty default', () => { + for (const bad of ['not json', '[]', '{"model":"not-a-number"}', '{"model":null}']) { + process.env.CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS = bad; + const quotas = loadConfig(app).observability.bedrockTpmQuotas; + expect(Object.values(quotas).every((v) => Number.isFinite(v))).toBe(true); + } + }); + test('booleans parse from env', () => { process.env.CDK_OBSERVABILITY_ALARM_TOPIC_ENABLED = 'false'; process.env.CDK_OBSERVABILITY_AGENTCORE_APPLICATION_LOGS_ENABLED = 'true'; @@ -1738,6 +1801,8 @@ describe('Observability Configuration', () => { promptCacheAvoidableMissThreshold: 22, promptCacheWastedUsdThreshold: 2.5, promptCacheSessionWastedUsdThreshold: 23, + bedrockTpmQuotaPercent: OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTA_PERCENT, + bedrockTpmQuotas: {}, }); }); }); diff --git a/infrastructure/test/helpers/mock-config.ts b/infrastructure/test/helpers/mock-config.ts index 75e45f6a9..8d447071d 100644 --- a/infrastructure/test/helpers/mock-config.ts +++ b/infrastructure/test/helpers/mock-config.ts @@ -13,6 +13,7 @@ import { AppConfig, MANAGED_KB_RETENTION_WINDOW_DAYS, OBSERVABILITY_DEFAULT_AGENTCORE_ERROR_THRESHOLD, OBSERVABILITY_DEFAULT_ALB_TARGET_5XX_THRESHOLD, + OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTA_PERCENT, OBSERVABILITY_DEFAULT_DYNAMO_THROTTLE_THRESHOLD, OBSERVABILITY_DEFAULT_ECS_CPU_PERCENT, OBSERVABILITY_DEFAULT_ECS_MEMORY_PERCENT, @@ -81,6 +82,16 @@ export function createMockConfig(overrides: Partial = {}): AppConfig OBSERVABILITY_DEFAULT_PROMPT_CACHE_WASTED_USD_THRESHOLD, promptCacheSessionWastedUsdThreshold: OBSERVABILITY_DEFAULT_PROMPT_CACHE_SESSION_WASTED_USD_THRESHOLD, + bedrockTpmQuotaPercent: OBSERVABILITY_DEFAULT_BEDROCK_TPM_QUOTA_PERCENT, + // Deliberately NOT the empty default: two entries with deliberately + // different orders of magnitude, so the per-model alarms exist in test + // stacks and the routing guard covers them. The real default is empty and + // is asserted separately in config.test.ts. Illustrative values — quotas + // are per-account, so these are not canonical for any deployment. + bedrockTpmQuotas: { + 'global.anthropic.claude-sonnet-5': 40_000_000, + 'us.anthropic.claude-sonnet-4-20250514-v1:0': 200_000, + }, xraySamplingRate: OBSERVABILITY_DEFAULT_XRAY_SAMPLING_RATE, xraySamplingReservoir: OBSERVABILITY_DEFAULT_XRAY_SAMPLING_RESERVOIR, xrayInsightsNotifications: false, @@ -106,6 +117,7 @@ export function createMockConfig(overrides: Partial = {}): AppConfig newDefault: false, migrationEnabled: false, reconcilerArmed: false, + docReconcilerArmed: false, perOwnerDefaultBytes: MANAGED_KB_DEFAULT_PER_OWNER_BYTES, perOwnerElevatedBytes: MANAGED_KB_ELEVATED_PER_OWNER_BYTES, perKnowledgeBaseCeilingBytes: MANAGED_KB_PER_KB_CEILING_BYTES, diff --git a/infrastructure/test/kb-migration.test.ts b/infrastructure/test/kb-migration.test.ts index 991cccfb2..d27eb04d0 100644 --- a/infrastructure/test/kb-migration.test.ts +++ b/infrastructure/test/kb-migration.test.ts @@ -45,6 +45,7 @@ const HANDLERS = { dispatcher: 'apis.app_api.kb_migration.dispatcher.lambda_handler', worker: 'apis.app_api.kb_migration.worker.lambda_handler', reconciler: 'apis.app_api.kb_migration.reconciler.lambda_handler', + documentReconciler: 'apis.app_api.kb_migration.document_reconciler.lambda_handler', ingestionConsumer: 'apis.app_api.kb_migration.ingestion_consumer.lambda_handler', } as const; @@ -177,15 +178,15 @@ describe('KbMigrationConstruct — Lambdas and image sharing', () => { t = synth(); }); - it('creates exactly four DockerImage Lambdas, one per handler', () => { - expect(migrationLambdas(t)).toHaveLength(4); + it('creates exactly five DockerImage Lambdas, one per handler', () => { + expect(migrationLambdas(t)).toHaveLength(5); for (const handler of Object.values(HANDLERS)) { const p = lambdaFor(t, handler); expect(p.PackageType).toBe('Image'); } }); - it('all four functions share ONE image asset', () => { + it('all five functions share ONE image asset', () => { // The whole point of the ImageConfig.Command override pattern: one // build, one ECR push, one out-of-band `update-function-code` per // deploy. Pointing any function at a second build context would work @@ -193,7 +194,7 @@ describe('KbMigrationConstruct — Lambdas and image sharing', () => { // while leaving that function on a stale image forever after — // because the backend workflow only ships one. const imageUris = migrationLambdas(t).map((p) => JSON.stringify(p.Code?.ImageUri)); - expect(imageUris).toHaveLength(4); + expect(imageUris).toHaveLength(5); for (const uri of imageUris) { expect(uri).not.toBe('undefined'); } @@ -210,8 +211,8 @@ describe('KbMigrationConstruct — Lambdas and image sharing', () => { } }); - it('publishes all four generated function names to SSM under /kb-migration/', () => { - for (const slug of ['dispatcher', 'worker', 'reconciler', 'ingestion-consumer']) { + it('publishes all five generated function names to SSM under /kb-migration/', () => { + for (const slug of ['dispatcher', 'worker', 'reconciler', 'document-reconciler', 'ingestion-consumer']) { t.hasResourceProperties('AWS::SSM::Parameter', { Name: `/test-project/kb-migration/${slug}-function-name`, }); @@ -233,6 +234,7 @@ describe('kb-migration bootstrap asset', () => { expect(entries).toEqual([ 'Dockerfile', 'dispatcher.py', + 'document_reconciler.py', 'ingestion_consumer.py', 'reconciler.py', 'worker.py', @@ -283,9 +285,9 @@ describe('kb-migration bootstrap asset', () => { // ============================================================ describe('KbMigrationConstruct — schedules', () => { - it('creates exactly two schedule rules plus the documents rule', () => { + it('creates exactly three schedule rules plus the documents rule', () => { const rules = Object.values(synth().findResources('AWS::Events::Rule')); - expect(rules).toHaveLength(3); + expect(rules).toHaveLength(4); }); it('dispatcher runs on a rate() schedule and targets the dispatcher', () => { @@ -308,6 +310,29 @@ describe('KbMigrationConstruct — schedules', () => { }); }); + it('document reconciler runs nightly on a fixed cron and targets the document reconciler', () => { + // A fixed off-peak hour (09:00 UTC ≈ 02:00-03:00 America/Denver) rather than + // rate(1 day), so "nightly" means night, not "24h after each deploy". + const t = synth(); + t.hasResourceProperties('AWS::Events::Rule', { + ScheduleExpression: 'cron(0 9 * * ? *)', + Targets: Match.arrayWith([ + Match.objectLike({ Arn: { 'Fn::GetAtt': [Match.stringLikeRegexp('KbDocumentReconcilerLambda'), 'Arn'] } }), + ]), + }); + }); + + it('document reconciler rule is ENABLED even with every flag off, because report-only is the point', () => { + // Same inverted convention as the KB reconciler: it ships report-only + // (MANAGED_KB_DOC_RECONCILER_ARMED off), report-only is read-only, and the + // audit period only happens if the schedule actually runs. + const t = synth({ newDefault: false, migrationEnabled: false, reconcilerArmed: false, docReconcilerArmed: false }); + t.hasResourceProperties('AWS::Events::Rule', { + ScheduleExpression: 'cron(0 9 * * ? *)', + State: 'ENABLED', + }); + }); + it('dispatcher rule is DISABLED when migrationEnabled is false', () => { const t = synth({ migrationEnabled: false }); t.hasResourceProperties('AWS::Events::Rule', { @@ -443,14 +468,15 @@ describe('KbMigrationConstruct — ingestion consumer (task 2.2)', () => { // ============================================================ describe('KbMigrationConstruct — flags reach every function', () => { - it('all three flags are "false" on all four functions by default', () => { + it('all flags are "false" on all five functions by default', () => { const t = synth(); const fns = migrationLambdas(t); - expect(fns).toHaveLength(4); + expect(fns).toHaveLength(5); for (const p of fns) { expect(env(p).MANAGED_KB_NEW_DEFAULT).toBe('false'); expect(env(p).MANAGED_KB_MIGRATION_ENABLED).toBe('false'); expect(env(p).MANAGED_KB_RECONCILER_ARMED).toBe('false'); + expect(env(p).MANAGED_KB_DOC_RECONCILER_ARMED).toBe('false'); } }); @@ -467,6 +493,14 @@ describe('KbMigrationConstruct — flags reach every function', () => { const p2 = lambdaFor(t2, HANDLERS.reconciler); expect(env(p2).MANAGED_KB_NEW_DEFAULT).toBe('false'); expect(env(p2).MANAGED_KB_RECONCILER_ARMED).toBe('true'); + expect(env(p2).MANAGED_KB_DOC_RECONCILER_ARMED).toBe('false'); + + // Arming the document reconciler must not arm the deleting KB reconciler, + // and vice versa — they are separate blast radii. + const t3 = synth({ reconcilerArmed: false, docReconcilerArmed: true }); + const p3 = lambdaFor(t3, HANDLERS.documentReconciler); + expect(env(p3).MANAGED_KB_DOC_RECONCILER_ARMED).toBe('true'); + expect(env(p3).MANAGED_KB_RECONCILER_ARMED).toBe('false'); }); it('gives the dispatcher the worker function name the Python actually reads', () => { @@ -524,6 +558,7 @@ describe('loadConfig — managedKb flag resolution (Requirements 19.5, 19.8)', ( 'CDK_MANAGED_KB_NEW_DEFAULT', 'CDK_MANAGED_KB_MIGRATION_ENABLED', 'CDK_MANAGED_KB_RECONCILER_ARMED', + 'CDK_MANAGED_KB_DOC_RECONCILER_ARMED', ] as const; let app: cdk.App; @@ -571,6 +606,7 @@ describe('loadConfig — managedKb flag resolution (Requirements 19.5, 19.8)', ( expect(config.managedKb.newDefault).toBe(false); expect(config.managedKb.migrationEnabled).toBe(false); expect(config.managedKb.reconcilerArmed).toBe(false); + expect(config.managedKb.docReconcilerArmed).toBe(false); }); it('resolves an EMPTY STRING to false, not to true', () => { @@ -586,6 +622,7 @@ describe('loadConfig — managedKb flag resolution (Requirements 19.5, 19.8)', ( expect(config.managedKb.newDefault).toBe(false); expect(config.managedKb.migrationEnabled).toBe(false); expect(config.managedKb.reconcilerArmed).toBe(false); + expect(config.managedKb.docReconcilerArmed).toBe(false); }); it('honours an explicit "true" per flag, independently', () => { @@ -852,11 +889,30 @@ describe('KbMigrationConstruct — IAM', () => { expect(holders.some((id) => /IngestionConsumerLambdaServiceRole/.test(id))).toBe(false); }); - it('reuses the task 1.2 direct-ingestion grant for the worker and ingestion consumer', () => { + it('reuses the task 1.2 direct-ingestion grant for the worker, ingestion consumer and document reconciler', () => { const holders = roleIdsWithSid('ManagedKbDirectIngestion'); - expect(holders).toHaveLength(2); + expect(holders).toHaveLength(3); expect(holders.some((id) => /WorkerLambdaServiceRole/.test(id))).toBe(true); expect(holders.some((id) => /IngestionConsumerLambdaServiceRole/.test(id))).toBe(true); + // The document reconciler re-ingests NOT_FOUND documents and probes + // GetKnowledgeBaseDocuments, both under this grant. + expect(holders.some((id) => /DocumentReconcilerLambdaServiceRole/.test(id))).toBe(true); + }); + + it('gives the document reconciler ingestion + retrieval but NOT provisioning or PassRole', () => { + // Its footprint is deliberately narrower than the KB reconciler beside it: + // it reads KB_Records from DynamoDB (not ListKnowledgeBases) and never + // creates or deletes a knowledge base, so it must hold neither the + // provisioning CRUD grant nor the iam:PassRole grant. + const ingesters = roleIdsWithSid('ManagedKbDirectIngestion'); + const retrievers = roleIdsWithSid('ManagedKbRetrieve'); + const provisioners = roleIdsWithSid('ManagedKbProvisionCrud'); + const passRole = roleIdsWithSid('ManagedKbPassServiceRole'); + const isDocReconciler = (id: string) => /DocumentReconcilerLambdaServiceRole/.test(id); + expect(ingesters.some(isDocReconciler)).toBe(true); + expect(retrievers.some(isDocReconciler)).toBe(true); + expect(provisioners.some(isDocReconciler)).toBe(false); + expect(passRole.some(isDocReconciler)).toBe(false); }); it('gives both ingesting roles bedrock:StartIngestionJob, not just the Ingest action', () => { @@ -866,13 +922,12 @@ describe('KbMigrationConstruct — IAM', () => { // matching name deploys and reviews clean, then returns // AccessDeniedException on the first document. // - // Both roles are checked because both call + // All THREE ingesting roles are checked because all call // `ingest_knowledge_base_documents`: the ingestion consumer surfaced it, - // and the worker had the identical gap — invisible until now only - // because every migration so far was driven locally under a broader SSO - // identity than the Lambda role. + // the worker had the identical gap, and the document reconciler re-ingests + // NOT_FOUND documents the same way. const statements = allStatements(t).filter((s) => s.Sid === 'ManagedKbDirectIngestion'); - expect(statements).toHaveLength(2); + expect(statements).toHaveLength(3); for (const s of statements) { expect(s.Action).toContain('bedrock:StartIngestionJob'); expect(s.Action).toContain('bedrock:IngestKnowledgeBaseDocuments'); @@ -997,8 +1052,8 @@ describe('KbMigration wiring on PlatformStack', () => { t = Template.fromStack(stack); }); - it('creates all four migration Lambdas in the platform stack', () => { - expect(migrationLambdas(t)).toHaveLength(4); + it('creates all five migration Lambdas in the platform stack', () => { + expect(migrationLambdas(t)).toHaveLength(5); }); it('keeps the existing rag-ingestion ObjectCreated notification intact', () => { @@ -1030,8 +1085,8 @@ describe('KbMigration wiring on PlatformStack', () => { expect(cfg.EventBridgeConfiguration).toBeDefined(); }); - it('publishes the four kb-migration function names for the code-deploy step', () => { - for (const slug of ['dispatcher', 'worker', 'reconciler', 'ingestion-consumer']) { + it('publishes the five kb-migration function names for the code-deploy step', () => { + for (const slug of ['dispatcher', 'worker', 'reconciler', 'document-reconciler', 'ingestion-consumer']) { t.hasResourceProperties('AWS::SSM::Parameter', { Name: `/test-project/kb-migration/${slug}-function-name`, }); diff --git a/infrastructure/test/managed-kb.test.ts b/infrastructure/test/managed-kb.test.ts index 186bc0aa2..30d731bf7 100644 --- a/infrastructure/test/managed-kb.test.ts +++ b/infrastructure/test/managed-kb.test.ts @@ -587,13 +587,15 @@ describe('Managed_KB retrieval wiring on PlatformStack', () => { it('attaches retrieval to the AgentCore Runtime role and the App API task role', () => { const roles = t.findResources('AWS::IAM::Role'); const holders = policiesWithSid(t, 'ManagedKbRetrieve'); - // FOUR holders as of task 2.1: the two compute identities that serve + // FIVE holders as of task 16.5: the two compute identities that serve // user turns, plus the migration worker (the `verify` canary - // retrieval, Requirement 15.7) and the ingestion consumer (polling - // until a document is actually retrievable, Requirement 10.6). The - // exact count is asserted so a fifth holder appearing has to be a - // deliberate edit here rather than a silent widening. - expect(holders).toHaveLength(4); + // retrieval, Requirement 15.7), the ingestion consumer (polling until + // a document is actually retrievable, Requirement 10.6), and the + // document reconciler (confirming a stranded document is retrievable + // before marking it complete, §5.37). The exact count is asserted so a + // sixth holder appearing has to be a deliberate edit here rather than a + // silent widening. + expect(holders).toHaveLength(5); const holderRoleIds = holders.flatMap((h) => h.roleIds); const holderRoles = holderRoleIds.map((id) => { @@ -612,10 +614,11 @@ describe('Managed_KB retrieval wiring on PlatformStack', () => { // its ecs-tasks trust principal. expect(holderRoles.some(trustsService(ECS_TASKS_PRINCIPAL))).toBe(true); - // 3. The remaining two are the migration Lambda roles, and nothing - // else: every holder must be one of those three trust shapes. + // 3. The remaining three are the migration Lambda roles (worker, + // ingestion consumer, document reconciler), and nothing else: + // every holder must be one of those three trust shapes. const lambdaHolders = holderRoles.filter(trustsService(LAMBDA_PRINCIPAL)); - expect(lambdaHolders).toHaveLength(2); + expect(lambdaHolders).toHaveLength(3); const unaccounted = holderRoles.filter( (r) => r.RoleName !== 'test-project-agentcore-runtime-role' && !trustsService(ECS_TASKS_PRINCIPAL)(r) diff --git a/infrastructure/test/observability-ai-path-alarms.test.ts b/infrastructure/test/observability-ai-path-alarms.test.ts index 7fe913c64..1a0d495a0 100644 --- a/infrastructure/test/observability-ai-path-alarms.test.ts +++ b/infrastructure/test/observability-ai-path-alarms.test.ts @@ -46,20 +46,23 @@ describe('AI-path alarms (Bedrock, Memory, Gateway, Code Interpreter)', () => { }); describe('Bedrock inference', () => { - it('alarms on throttles, server errors, and quota usage', () => { + // The two quota alarms mock-config configures, and their 75%-of-quota + // thresholds. Kept next to the assertions so a mock change that silently + // drops a model fails here rather than reducing coverage unnoticed. + const SONNET_5 = 'bedrock-tpm-quota-usage-global-anthropic-claude-sonnet-5'; + const SONNET_4 = 'bedrock-tpm-quota-usage-us-anthropic-claude-sonnet-4-20250514-v1-0'; + + it('alarms on throttles and server errors', () => { for (const name of [ 'bedrock-invocation-throttles', 'bedrock-invocation-server-errors', - 'bedrock-tpm-quota-usage', ]) { expect(byName(name).Properties.Namespace).toBe('AWS/Bedrock'); } }); - it('uses the account-wide roll-up rather than per-model alarms', () => { - for (const name of ['bedrock-invocation-throttles', 'bedrock-tpm-quota-usage']) { - expect(byName(name).Properties.Dimensions).toBeUndefined(); - } + it('uses the account-wide roll-up for throttles, which need no denominator', () => { + expect(byName('bedrock-invocation-throttles').Properties.Dimensions).toBeUndefined(); }); it('throttle alarm fires on any throttle', () => { @@ -70,11 +73,40 @@ describe('AI-path alarms (Bedrock, Memory, Gateway, Code Interpreter)', () => { expect(alarm.Properties.TreatMissingData).toBe('notBreaching'); }); - it('quota-usage alarm is a percentage gauge on a metric that has live data', () => { - const alarm = byName('bedrock-tpm-quota-usage'); - expect(alarm.Properties.MetricName).toBe('EstimatedTPMQuotaUsage'); - expect(alarm.Properties.Statistic).toBe('Maximum'); - expect(alarm.Properties.Threshold).toBe(80); + // Regression guard. The original alarm compared this metric against 80 as if + // it were a percentage; it is an absolute token count, so 80 was crossed by + // roughly one sentence of output and the alarm sat in ALARM ~99% of the time, + // clearing only when a period had no data at all. + it('creates no account-wide quota alarm, which could not be a percentage', () => { + // Sort both sides: alarm discovery order is not guaranteed. + expect(allNames().filter((n) => /tpm-quota/.test(n)).sort()) + .toEqual([`${MOCK_PREFIX}-${SONNET_5}`, `${MOCK_PREFIX}-${SONNET_4}`].sort()); + for (const n of allNames()) { + expect(n).not.toBe(`${MOCK_PREFIX}-bedrock-tpm-quota-usage`); + } + }); + + it('quota alarms are per-model, scoped by the ModelId dimension', () => { + for (const [name, modelId] of [ + [SONNET_5, 'global.anthropic.claude-sonnet-5'], + [SONNET_4, 'us.anthropic.claude-sonnet-4-20250514-v1:0'], + ] as const) { + const alarm = byName(name); + expect(alarm.Properties.MetricName).toBe('EstimatedTPMQuotaUsage'); + expect(alarm.Properties.Namespace).toBe('AWS/Bedrock'); + expect(alarm.Properties.Dimensions).toEqual([{ Name: 'ModelId', Value: modelId }]); + } + }); + + // Maximum, not Average: the quota is per minute, the period is five, and the + // underlying data is 1-minute. Averaging would dilute a real spike. + it('quota alarm threshold is the configured percent of each model quota', () => { + expect(byName(SONNET_5).Properties.Threshold).toBe(30_000_000); // 75% of 40M + expect(byName(SONNET_4).Properties.Threshold).toBe(150_000); // 75% of 200k + for (const name of [SONNET_5, SONNET_4]) { + expect(byName(name).Properties.Statistic).toBe('Maximum'); + expect(byName(name).Properties.TreatMissingData).toBe('notBreaching'); + } }); }); @@ -182,7 +214,9 @@ describe('AI-path alarms (Bedrock, Memory, Gateway, Code Interpreter)', () => { it('all AI-path alarms are routed to the alarm topic', () => { for (const name of [ 'bedrock-invocation-throttles', 'bedrock-invocation-server-errors', - 'bedrock-tpm-quota-usage', 'agentcore-memory-system-errors', + 'bedrock-tpm-quota-usage-global-anthropic-claude-sonnet-5', + 'bedrock-tpm-quota-usage-us-anthropic-claude-sonnet-4-20250514-v1-0', + 'agentcore-memory-system-errors', 'agentcore-memory-throttles', 'agentcore-gateway-system-errors', 'agentcore-gateway-throttles', 'agentcore-code-interpreter-system-errors', 'agentcore-code-interpreter-active-sessions', diff --git a/infrastructure/test/observability-alarm-routing.test.ts b/infrastructure/test/observability-alarm-routing.test.ts index 72a359065..3156ffcf9 100644 --- a/infrastructure/test/observability-alarm-routing.test.ts +++ b/infrastructure/test/observability-alarm-routing.test.ts @@ -33,6 +33,7 @@ describe('Alarm routing — every alarm reaches a human', () => { newDefault: true, migrationEnabled: true, reconcilerArmed: true, + docReconcilerArmed: true, perOwnerDefaultBytes: 100 * 1024 * 1024, perOwnerElevatedBytes: 1024 * 1024 * 1024, perKnowledgeBaseCeilingBytes: 500 * 1024 * 1024, diff --git a/infrastructure/test/observability-lambda-alarms.test.ts b/infrastructure/test/observability-lambda-alarms.test.ts index 0d929358d..f3f3a2c37 100644 --- a/infrastructure/test/observability-lambda-alarms.test.ts +++ b/infrastructure/test/observability-lambda-alarms.test.ts @@ -38,6 +38,7 @@ describe('Lambda and DLQ alarms', () => { newDefault: true, migrationEnabled: true, reconcilerArmed: true, + docReconcilerArmed: true, perOwnerDefaultBytes: 100 * 1024 * 1024, perOwnerElevatedBytes: 1024 * 1024 * 1024, perKnowledgeBaseCeilingBytes: 500 * 1024 * 1024, diff --git a/scripts/build/deploy-image-lambda-one.sh b/scripts/build/deploy-image-lambda-one.sh index d99bcb2d9..aa2a54f65 100755 --- a/scripts/build/deploy-image-lambda-one.sh +++ b/scripts/build/deploy-image-lambda-one.sh @@ -73,6 +73,11 @@ case "$SERVICE" in IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" ECR_REPO_URI="${REGISTRY}/${CDK_PROJECT_PREFIX}-kb-migration" ;; + kb-migration-document-reconciler) + FUNCTION_NAME_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/document-reconciler-function-name" + IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" + ECR_REPO_URI="${REGISTRY}/${CDK_PROJECT_PREFIX}-kb-migration" + ;; kb-migration-ingestion-consumer) FUNCTION_NAME_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/ingestion-consumer-function-name" IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" @@ -90,7 +95,7 @@ case "$SERVICE" in ;; *) echo "Unknown service: $SERVICE" >&2 - echo "Expected one of: rag-ingestion | kb-sync-dispatcher | kb-sync-worker | scheduled-runs-dispatcher | scheduled-runs-worker | kb-migration-dispatcher | kb-migration-worker | kb-migration-reconciler | kb-migration-ingestion-consumer" >&2 + echo "Expected one of: rag-ingestion | kb-sync-dispatcher | kb-sync-worker | scheduled-runs-dispatcher | scheduled-runs-worker | kb-migration-dispatcher | kb-migration-worker | kb-migration-reconciler | kb-migration-document-reconciler | kb-migration-ingestion-consumer" >&2 exit 1 ;; esac diff --git a/scripts/common/load-env.sh b/scripts/common/load-env.sh index f9e1487f6..93f49d42b 100644 --- a/scripts/common/load-env.sh +++ b/scripts/common/load-env.sh @@ -231,6 +231,9 @@ build_cdk_context_params() { if [ -n "${CDK_MANAGED_KB_RECONCILER_ARMED:-}" ]; then context_params="${context_params} --context managedKb.reconcilerArmed=\"${CDK_MANAGED_KB_RECONCILER_ARMED}\"" fi + if [ -n "${CDK_MANAGED_KB_DOC_RECONCILER_ARMED:-}" ]; then + context_params="${context_params} --context managedKb.docReconcilerArmed=\"${CDK_MANAGED_KB_DOC_RECONCILER_ARMED}\"" + fi # Byte_Caps in BYTES (Requirement 12.2) and the rollback window in DAYS # (Requirement 15.11). config.ts reads the same flat dotted keys, so these # are honoured rather than silently dropped. @@ -288,6 +291,32 @@ build_cdk_context_params() { if [ -n "${CDK_OBSERVABILITY_ECS_MEMORY_PERCENT:-}" ]; then context_params="${context_params} --context observability.ecsMemoryPercent=\"${CDK_OBSERVABILITY_ECS_MEMORY_PERCENT}\"" fi + if [ -n "${CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT:-}" ]; then + context_params="${context_params} --context observability.bedrockTpmQuotaPercent=\"${CDK_OBSERVABILITY_BEDROCK_TPM_QUOTA_PERCENT}\"" + fi + # The one non-scalar observability tunable: a map of Bedrock ModelId to that + # model's TPM quota. Two accepted forms: + # + # global.anthropic.claude-sonnet-5=40000000,us.amazon.nova-micro-v1:0=8000000 + # {"global.anthropic.claude-sonnet-5":40000000} + # + # PREFER THE FIRST. deploy.sh runs `eval npx cdk synth ${CDK_CONTEXT_PARAMS}`, + # and eval removes quote characters, so JSON passed the way every other value + # here is passed arrives as {model:40000000} — no longer valid JSON. It would + # then fall back to the empty default and create NO alarms, with no error. + # Hence: single quotes below so JSON survives too, and a hard failure if the + # value contains a single quote, which would break that quoting in turn. + # Unset means no per-model quota alarms are created. + if [ -n "${CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS:-}" ]; then + case "${CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS}" in + *"'"*) + echo "ERROR: CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS must not contain a single quote." >&2 + echo " Use the eval-safe form: modelId=quota,modelId=quota" >&2 + return 1 + ;; + esac + context_params="${context_params} --context observability.bedrockTpmQuotas='${CDK_OBSERVABILITY_BEDROCK_TPM_QUOTAS}'" + fi if [ -n "${CDK_OBSERVABILITY_XRAY_SAMPLING_RATE:-}" ]; then context_params="${context_params} --context observability.xraySamplingRate=\"${CDK_OBSERVABILITY_XRAY_SAMPLING_RATE}\"" fi @@ -399,6 +428,8 @@ export CDK_ARTIFACTS_RETENTION_DAYS="${CDK_ARTIFACTS_RETENTION_DAYS:-$(get_json_ # newDefault — new knowledge bases are created managed # migrationEnabled — the background migration worker runs at all # reconcilerArmed — the daily reconciler DELETES rather than only reporting +# docReconcilerArmed — the nightly document reconciler CORRECTS stranded DOC# +# rows rather than only reporting # # Empty is safe and is the shipped state. Unlike the default-ON flags # above, there is no "kill switch" reading here to get wrong: nothing @@ -406,6 +437,7 @@ export CDK_ARTIFACTS_RETENTION_DAYS="${CDK_ARTIFACTS_RETENTION_DAYS:-$(get_json_ export CDK_MANAGED_KB_NEW_DEFAULT="${CDK_MANAGED_KB_NEW_DEFAULT:-$(get_json_value "managedKb.newDefault" "${CONTEXT_FILE}")}" export CDK_MANAGED_KB_MIGRATION_ENABLED="${CDK_MANAGED_KB_MIGRATION_ENABLED:-$(get_json_value "managedKb.migrationEnabled" "${CONTEXT_FILE}")}" export CDK_MANAGED_KB_RECONCILER_ARMED="${CDK_MANAGED_KB_RECONCILER_ARMED:-$(get_json_value "managedKb.reconcilerArmed" "${CONTEXT_FILE}")}" +export CDK_MANAGED_KB_DOC_RECONCILER_ARMED="${CDK_MANAGED_KB_DOC_RECONCILER_ARMED:-$(get_json_value "managedKb.docReconcilerArmed" "${CONTEXT_FILE}")}" # Per-owner / per-knowledge-base Byte_Caps, in BYTES (Requirement 12.2), and # the legacy-vector rollback window in DAYS (Requirement 15.11). Defaults live # in config.ts as named constants (100 MB / 1 GB / 500 MB / 30 days); these diff --git a/scripts/load-test/README.md b/scripts/load-test/README.md new file mode 100644 index 000000000..7e5b682fb --- /dev/null +++ b/scripts/load-test/README.md @@ -0,0 +1,144 @@ +# Load-test provisioning + +Creates and destroys the Cognito users a load run needs. The load test itself +lives in [`tests/load/`](../../tests/load/) and has no AWS credentials — these +scripts are the only part that touches your account. + +> **Not run by CI, and not safe to wire into a workflow.** Both scripts mutate +> live shared state: users are created in the same pool real people sign in to, +> and `provision.sh` writes quota overrides that **switch off cost limits** for +> those users. Same posture as `scripts/observability/set-bsu-overrides.sh` — +> confirmation required, `--dry-run` available, never automated. + +## Why provisioning is needed at all + +Two of the platform's own safety rails block a load test, by design: + +1. **`FORCE_CHANGE_PASSWORD` blocks scripted login.** A freshly created Cognito + user cannot complete the Hosted UI form until it has a permanent password. +2. **Per-user cost quotas hard-stop sustained traffic.** Without an override the + run stops measuring the chat path and starts measuring quota enforcement. + +Neither can be worked around from the test side, which is why this is a separate +step with its own gate rather than something the locustfile does. + +## Usage + +```bash +export CDK_PROJECT_PREFIX=your-prefix +export CDK_AWS_REGION=us-west-2 +export AWS_PROFILE=your-profile # the devcontainer sets AWS_REGION but no profile + +# See the plan without changing anything +scripts/load-test/provision.sh --users 10 --quota-days 1 --dry-run + +# Do it +scripts/load-test/provision.sh --users 10 --quota-days 1 +``` + +Both scripts print the resolved AWS account before the plan and again in the +plan itself. **Read it.** The prefix alone does not tell you which account you +are aimed at, and these scripts create real users and switch off real cost +controls. + +If `AWS_PROFILE` is unset, every call falls through to the default credential +chain, which in the devcontainer is not your SSO session. + +`provision.sh` prints the exact environment exports for the run when it +finishes. Then, when the run is done: + +```bash +scripts/load-test/teardown.sh --manifest ~/.config/agentcore-load/users-.json +``` + +Run inside the devcontainer — the scripts need `aws`, `jq`, `openssl` and GNU `date`. + +## Watching the Bedrock quota during a run + +```bash +scripts/load-test/watch-tpm.sh --model-id global.anthropic.claude-sonnet-5 +``` + +Read-only. Polls CloudWatch `EstimatedTPMQuotaUsage` in five-minute windows and +prints tokens/min, percent of the **applied** quota, turns/min, and implied +tokens/turn. It warns when the applied quota equals the AWS default, since that +means no increase has ever landed — the usual reason a capacity plan assumes +headroom it does not have. + +The quota code is discovered from the model id: a `global.` prefix resolves to +the Global cross-region limit, `us.`/`eu.`/`apac.` to the plain cross-region one. +Pass `--quota-code` if discovery is ambiguous, or `--quota N` to skip the lookup. + +Five-minute windows rather than one-minute are deliberate: a single observed +production minute reported 546,206 quota tokens against one invocation, which +exceeds that model's own context window, so per-minute peaks are not trustworthy. + +This lives here rather than in `tests/load/` because the load generator has no +AWS credentials by design. + +## The manifest + +`provision.sh` writes a `0600` JSON file, by default under +`~/.config/agentcore-load/`, deliberately outside the repo tree: + +```json +[ + { + "username": "loadtest-20260902-221500-01", + "password": "...", + "user_id": "", + "override_id": "loadtest-20260902-221500-1" + } +] +``` + +It holds **plaintext passwords and is the only copy** — Cognito will not show +them again. `teardown.sh` needs it to know what to delete, and deletes it +afterwards unless you pass `--keep-manifest`. + +## What gets created + +Per user: + +| Step | Call | Note | +|---|---|---| +| User | `cognito-idp admin-create-user` | `MessageAction=SUPPRESS`, so no mail is sent. The email attribute is required by the pool, so one is set at `@load.invalid` — non-routable per RFC 6761 — with `email_verified=true`. | +| Password | `cognito-idp admin-set-user-password --permanent` | Moves the user to `CONFIRMED` from any state. | +| Quota override | `dynamodb put-item` | An `unlimited` override, time-bounded by `--quota-days`. | + +The override is written straight to the quota table rather than through the +admin API, because the admin API needs an authenticated admin session — which +would mean solving the login problem to solve the login problem. The item +mirrors `QuotaRepository.create_override`: `PK=OVERRIDE#`, `SK=METADATA`, +plus the `GSI4PK`/`GSI4SK` pair that `get_active_override` queries. It is keyed +on the Cognito **`sub`**, since that is what the app uses as `user_id`. + +## Safety rails + +- **Username prefix check.** Every entry in a manifest must start with + `loadtest-`, verified across the whole file *before* anything is deleted. A + hand-edited or swapped manifest cannot become a tool for deleting real users, + and cannot delete a subset before failing. +- **Overrides are deleted before users.** If teardown is interrupted, the + leftover you want is not "a live account with no cost limit." +- **Teardown fails loudly on a stuck override.** It keeps the manifest and exits + non-zero, because a silently retained override is a disabled cost control. +- **Credentials never pass through argv.** `ps` is world-readable; passwords go + via `0600` temp files and `--cli-input-json`. +- **Re-runnable.** An existing user is not an error; the password is reset and + the override rewritten. + +## If teardown fails + +Overrides are visible in the admin dashboard under **Quota Overrides** and can +be removed there. Users are removable with `admin-delete-user`. Confirm nothing +is left behind with: + +```bash +aws cognito-idp list-users \ + --user-pool-id "$(aws ssm get-parameter \ + --name "/${CDK_PROJECT_PREFIX}/auth/cognito/user-pool-id" \ + --query Parameter.Value --output text)" \ + --filter 'username ^= "loadtest-"' \ + --query 'Users[].Username' +``` diff --git a/scripts/load-test/lib.sh b/scripts/load-test/lib.sh new file mode 100644 index 000000000..42d4b5b53 --- /dev/null +++ b/scripts/load-test/lib.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# Shared helpers for scripts/load-test/. Sourced, not executed. +# +# Design notes worth knowing before editing: +# +# * No Python. The devcontainer has no system interpreter at all — only +# uv-managed ones, off PATH — so these scripts use `jq` and GNU `date`, +# which are present and are the right tools for the job anyway. +# * Credentials never travel through argv. `ps` is world-readable and this +# mints many long-lived passwords at once, so every AWS call carrying a +# password does it through a 0600 temp file and --cli-input-json. + +# Usernames all share this prefix. teardown.sh refuses to act on a manifest +# containing anything else, so a hand-edited manifest cannot be turned into a +# tool for deleting real users. +# +# shellcheck disable=SC2034 # consumed by provision.sh / teardown.sh after sourcing +USERNAME_PREFIX="loadtest-" + +# Non-routable by design (RFC 6761 reserves .invalid), so a misconfigured pool +# can never deliver mail to a real inbox. +LOAD_TEST_EMAIL_DOMAIN="${LOAD_TEST_EMAIL_DOMAIN:-load.invalid}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_plan() { echo -e "${BLUE}[PLAN]${NC} $1"; } + +# Temp files hold credentials; make sure they never survive the process. +_LOAD_TEST_TMPDIR="" +_cleanup_tmp() { + if [ -n "${_LOAD_TEST_TMPDIR}" ] && [ -d "${_LOAD_TEST_TMPDIR}" ]; then + rm -rf "${_LOAD_TEST_TMPDIR}" + fi +} +trap _cleanup_tmp EXIT INT TERM + +_tmpdir() { + if [ -z "${_LOAD_TEST_TMPDIR}" ]; then + _LOAD_TEST_TMPDIR="$(mktemp -d)" + chmod 700 "${_LOAD_TEST_TMPDIR}" + fi + printf '%s' "${_LOAD_TEST_TMPDIR}" +} + +require_env() { + local missing=() + [ -z "${CDK_PROJECT_PREFIX:-}" ] && missing+=("CDK_PROJECT_PREFIX") + + # Accept the usual region variables so this works with an already-configured + # shell or the devcontainer's AWS_REGION. + CDK_AWS_REGION="${CDK_AWS_REGION:-${AWS_REGION:-${AWS_DEFAULT_REGION:-}}}" + [ -z "${CDK_AWS_REGION}" ] && missing+=("CDK_AWS_REGION (or AWS_REGION)") + + if [ ${#missing[@]} -gt 0 ]; then + log_error "Missing required environment variables: ${missing[*]}" + exit 1 + fi + export CDK_AWS_REGION + + for tool in aws jq openssl date; do + if ! command -v "${tool}" >/dev/null 2>&1; then + log_error "${tool} is required but not on PATH. Run inside the devcontainer." + exit 1 + fi + done + + # The timestamp format below needs GNU date's %N and -d. BSD/macOS date + # silently produces something else, which would break the string-compared + # validUntil. + if ! date -u -d "+1 day" +%N >/dev/null 2>&1; then + log_error "GNU date is required (BSD/macOS date lacks -d and %N)." + exit 1 + fi + + require_credentials +} + +# Fail on credentials before the plan prints, and name the account. +# +# Two reasons this is worth its own preflight rather than letting the first API +# call fail. The devcontainer sets AWS_REGION but no AWS_PROFILE, so an +# unexported profile sends every call to whatever the default chain resolves — +# which surfaced as a bogus "is your prefix correct?" on the first SSM read. +# And this script creates users in a live pool and switches off their cost +# limits, so which account it is aimed at is the single most important thing to +# state out loud before doing any of that. +require_credentials() { + local identity + local err + err="$(_tmpdir)/sts-err" + + if ! identity="$(aws sts get-caller-identity \ + --query "Account" --output text \ + --region "${CDK_AWS_REGION}" 2>"${err}")"; then + log_error "AWS credentials are not usable." + if [ -s "${err}" ]; then + log_error "AWS said: $(tr '\n' ' ' <"${err}")" + fi + log_error "Set AWS_PROFILE (the devcontainer does not set one) or run 'aws sso login'." + exit 1 + fi + + export LOAD_TEST_AWS_ACCOUNT="${identity}" + log_info "AWS account ${identity}, region ${CDK_AWS_REGION}, profile ${AWS_PROFILE:-}" +} + +# Run IDs end up inside usernames and DynamoDB keys, so constrain them rather +# than interpolating arbitrary input. +validate_run_id() { + if ! [[ "$1" =~ ^[A-Za-z0-9-]+$ ]]; then + log_error "Run ID must match ^[A-Za-z0-9-]+\$ (got '$1')" + exit 1 + fi +} + +confirm_or_exit() { + local assume_yes="$1" + local prompt="$2" + + if [ "${assume_yes}" = true ]; then + log_warn "--yes given; skipping confirmation." + return 0 + fi + + local reply + read -r -p "$(echo -e "${YELLOW}${prompt}${NC} [y/N] ")" reply + case "${reply}" in + [yY]|[yY][eE][sS]) return 0 ;; + *) log_info "Aborted."; exit 0 ;; + esac +} + +_ssm_value() { + local name="$1" + local value + local err + err="$(_tmpdir)/ssm-err" + + value="$(aws ssm get-parameter \ + --name "${name}" \ + --query "Parameter.Value" \ + --output text \ + --region "${CDK_AWS_REGION}" 2>"${err}" || echo "")" + + if [ -z "${value}" ] || [ "${value}" = "None" ]; then + log_error "Could not resolve SSM parameter: ${name}" + # Print what AWS actually said. ExpiredToken, AccessDenied and + # ParameterNotFound are three different problems with three different + # fixes, and discarding stderr made them indistinguishable — every one + # of them read as "your prefix is wrong". + if [ -s "${err}" ]; then + log_error "AWS said: $(tr '\n' ' ' <"${err}")" + fi + log_error "Prefix '${CDK_PROJECT_PREFIX}', region '${CDK_AWS_REGION}'." + exit 1 + fi + printf '%s' "${value}" +} + +resolve_user_pool_id() { + _ssm_value "/${CDK_PROJECT_PREFIX}/auth/cognito/user-pool-id" +} + +resolve_quota_table() { + _ssm_value "/${CDK_PROJECT_PREFIX}/quota/user-quotas-table-name" +} + +# Derived from the pool rather than read from SSM: CDK computes the Hosted UI +# URL at synth time for the app-api environment and does not publish it as a +# parameter, so asking Cognito is the only source that cannot drift. +resolve_cognito_domain_url() { + local user_pool_id="$1" + local domain_prefix + domain_prefix="$(aws cognito-idp describe-user-pool \ + --user-pool-id "${user_pool_id}" \ + --query "UserPool.Domain" \ + --output text \ + --region "${CDK_AWS_REGION}" 2>/dev/null || echo "")" + + if [ -z "${domain_prefix}" ] || [ "${domain_prefix}" = "None" ]; then + log_error "User pool ${user_pool_id} has no Hosted UI domain." + log_error "Scripted login needs the Hosted UI; see tests/load/README.md." + exit 1 + fi + printf 'https://%s.auth.%s.amazoncognito.com' "${domain_prefix}" "${CDK_AWS_REGION}" +} + +# Emit a timestamp shaped exactly like the one the application compares against. +# +# `QuotaRepository.get_active_override` builds its comparison value as +# `datetime.now(timezone.utc).isoformat() + 'Z'`, which yields a +# microsecond-precision '+00:00Z' suffix, and then compares GSI4SK *as a +# string*. `%6N+00:00Z` reproduces that byte for byte; a plain `date -u ...Z` +# would emit a different suffix and could sort wrong at a boundary. +app_timestamp() { + local days_ahead="${1:-0}" + date -u -d "+${days_ahead} days" +%Y-%m-%dT%H:%M:%S.%6N+00:00Z +} + +# 24 chars with all four character classes, so any reasonable pool password +# policy is satisfied without having to read the policy. +# +# Restricted to [A-Za-z0-9] plus the fixed 'Lt' prefix and '9!' suffix. That is +# not only about policy: it keeps the value free of anything needing JSON or +# shell escaping. `assert_safe_password` enforces the invariant so a future +# change to this function cannot silently corrupt a manifest. +generate_password() { + local body + body="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 20)" + printf 'Lt%s9!' "${body}" +} + +assert_safe_password() { + if ! [[ "$1" =~ ^[A-Za-z0-9!]+$ ]]; then + log_error "Generated password contains unexpected characters." + log_error "generate_password must stay within [A-Za-z0-9!] — see its comment." + exit 1 + fi +} + +# Write an 'unlimited' quota override. Key layout mirrors +# QuotaRepository.create_override exactly — PK/SK plus the GSI4 pair that +# get_active_override queries through the UserOverrideIndex. +put_unlimited_override() { + local table="$1" override_id="$2" user_id="$3" + local valid_from="$4" valid_until="$5" run_id="$6" + + local item_file + item_file="$(_tmpdir)/override-${override_id}.json" + + ( umask 077 + jq -n \ + --arg overrideId "${override_id}" \ + --arg userId "${user_id}" \ + --arg validFrom "${valid_from}" \ + --arg validUntil "${valid_until}" \ + --arg runId "${run_id}" \ + '{ + PK: {S: ("OVERRIDE#" + $overrideId)}, + SK: {S: "METADATA"}, + GSI4PK: {S: ("USER#" + $userId)}, + GSI4SK: {S: ("VALID_UNTIL#" + $validUntil)}, + overrideId: {S: $overrideId}, + userId: {S: $userId}, + overrideType: {S: "unlimited"}, + validFrom: {S: $validFrom}, + validUntil: {S: $validUntil}, + reason: {S: ("Load test run " + $runId + " (scripts/load-test/provision.sh)")}, + createdBy: {S: "load-test-provisioner"}, + createdAt: {S: $validFrom}, + enabled: {BOOL: true} + }' > "${item_file}" + ) + + aws dynamodb put-item \ + --table-name "${table}" \ + --item "file://${item_file}" \ + --region "${CDK_AWS_REGION}" \ + --no-cli-pager >/dev/null + + rm -f "${item_file}" +} + +delete_override() { + local table="$1" override_id="$2" + local key_file + key_file="$(_tmpdir)/key-${override_id}.json" + + jq -n --arg overrideId "${override_id}" \ + '{PK: {S: ("OVERRIDE#" + $overrideId)}, SK: {S: "METADATA"}}' > "${key_file}" + + aws dynamodb delete-item \ + --table-name "${table}" \ + --key "file://${key_file}" \ + --region "${CDK_AWS_REGION}" \ + --no-cli-pager >/dev/null + + local status=$? + rm -f "${key_file}" + return "${status}" +} + +# Password goes via a 0600 file, not argv — see the note at the top. +set_permanent_password() { + local user_pool_id="$1" username="$2" password="$3" + + local input_file + input_file="$(_tmpdir)/pw-${username}.json" + + ( umask 077 + jq -n \ + --arg pool "${user_pool_id}" \ + --arg user "${username}" \ + --arg pass "${password}" \ + '{UserPoolId: $pool, Username: $user, Password: $pass, Permanent: true}' \ + > "${input_file}" + ) + + aws cognito-idp admin-set-user-password \ + --cli-input-json "file://${input_file}" \ + --region "${CDK_AWS_REGION}" \ + --no-cli-pager >/dev/null + + rm -f "${input_file}" +} + +# JSON-encode one manifest entry. jq handles the escaping that hand-rolled +# string interpolation would get wrong. +json_entry() { + jq -n \ + --arg username "$1" \ + --arg password "$2" \ + --arg user_id "$3" \ + --arg override_id "$4" \ + '{username: $username, password: $password, user_id: $user_id, override_id: $override_id}' +} diff --git a/scripts/load-test/provision.sh b/scripts/load-test/provision.sh new file mode 100755 index 000000000..60ba2040e --- /dev/null +++ b/scripts/load-test/provision.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# Provision Cognito users + quota overrides for a load-test run. +# +# scripts/load-test/provision.sh --users 10 --quota-days 1 +# +# NOT RUN BY CI, and deliberately so. This mutates live shared state in the +# target account: it creates Cognito users in the same pool real people sign in +# to, and it writes quota overrides that DISABLE COST CONTROLS for those users. +# Same posture as scripts/observability/set-bsu-overrides.sh — confirmation +# required, --dry-run available, never wired into a workflow. +# +# What it does, per user: +# 1. cognito-idp admin-create-user (MessageAction=SUPPRESS, no mail sent) +# 2. cognito-idp admin-set-user-password --permanent +# Required: FORCE_CHANGE_PASSWORD blocks scripted Hosted-UI login. +# 3. dynamodb put-item -> an 'unlimited' quota override, time-bounded +# Required: sustained turns otherwise trip the per-user cost limit and +# the run measures quota enforcement instead of the chat path. +# +# Output is a 0600 manifest consumed by tests/load via +# AGENTCORE_LOAD_USERS_FILE. It contains PLAINTEXT PASSWORDS — it is the only +# copy, it is not in the repo tree by default, and teardown.sh needs it. +# +# Required environment: +# CDK_PROJECT_PREFIX resolves SSM parameters +# CDK_AWS_REGION (or AWS_REGION) +# AWS_PROFILE unless the default credential chain is already correct +# (the devcontainer sets AWS_REGION but no profile) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=./lib.sh +source "${SCRIPT_DIR}/lib.sh" + +USER_COUNT=10 +QUOTA_DAYS=1 +RUN_ID="" +MANIFEST="" +DRY_RUN=false +ASSUME_YES=false + +usage() { + cat <<'EOF' +Usage: provision.sh [options] + + --users N Number of Cognito users to create (default 10) + --quota-days N Days the unlimited quota override stays valid (default 1) + --run-id ID Tag for this batch; defaults to a UTC timestamp + --manifest PATH Where to write credentials + (default ~/.config/agentcore-load/users-.json) + --email-domain D Domain for the required email attribute + (default load.invalid — intentionally non-routable) + --dry-run Print the plan; read SSM but make no changes + --yes Skip the confirmation prompt (for a trusted wrapper) + -h, --help This message +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --users) USER_COUNT="$2"; shift 2 ;; + --quota-days) QUOTA_DAYS="$2"; shift 2 ;; + --run-id) RUN_ID="$2"; shift 2 ;; + --manifest) MANIFEST="$2"; shift 2 ;; + --email-domain) LOAD_TEST_EMAIL_DOMAIN="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --yes) ASSUME_YES=true; shift ;; + -h|--help) usage; exit 0 ;; + *) log_error "Unknown option: $1"; usage; exit 1 ;; + esac +done + +if ! [[ "${USER_COUNT}" =~ ^[0-9]+$ ]] || [ "${USER_COUNT}" -lt 1 ]; then + log_error "--users must be a positive integer (got '${USER_COUNT}')" + exit 1 +fi +if ! [[ "${QUOTA_DAYS}" =~ ^[0-9]+$ ]] || [ "${QUOTA_DAYS}" -lt 1 ]; then + log_error "--quota-days must be a positive integer (got '${QUOTA_DAYS}')" + exit 1 +fi + +require_env +RUN_ID="${RUN_ID:-$(date -u +%Y%m%d-%H%M%S)}" +validate_run_id "${RUN_ID}" +MANIFEST="${MANIFEST:-${HOME}/.config/agentcore-load/users-${RUN_ID}.json}" + +# --------------------------------------------------------------------------- +# Resolve targets. Reads only — safe in --dry-run, and doing it before the +# prompt means the plan shown is the plan that will run. +# --------------------------------------------------------------------------- +USER_POOL_ID="$(resolve_user_pool_id)" +QUOTA_TABLE="$(resolve_quota_table)" +COGNITO_DOMAIN_URL="$(resolve_cognito_domain_url "${USER_POOL_ID}")" + +cat </dev/null || true + +# Create the manifest empty and locked down BEFORE writing any password into +# it, so there is no window where it exists world-readable. +: > "${MANIFEST}" +chmod 600 "${MANIFEST}" + +VALID_FROM="$(app_timestamp 0)" +VALID_UNTIL="$(app_timestamp "${QUOTA_DAYS}")" + +entries=() +created=0 + +for index in $(seq 1 "${USER_COUNT}"); do + username="$(printf '%s%s-%02d' "${USERNAME_PREFIX}" "${RUN_ID}" "${index}")" + email="${username}@${LOAD_TEST_EMAIL_DOMAIN}" + password="$(generate_password)" + # Guards the [A-Za-z0-9!] invariant that keeps the value safe to embed in + # JSON and shell without escaping. See generate_password. + assert_safe_password "${password}" + + log_info "[${index}/${USER_COUNT}] ${username}" + + # MessageAction=SUPPRESS: no invitation mail. email_verified=true so the + # pool's autoVerify never tries to reach the non-routable address. + if ! aws cognito-idp admin-create-user \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${username}" \ + --user-attributes "Name=email,Value=${email}" "Name=email_verified,Value=true" \ + --message-action SUPPRESS \ + --region "${CDK_AWS_REGION}" \ + --no-cli-pager >/dev/null 2>&1; then + # Already existing is fine and makes the script re-runnable; anything + # else is not, so surface it by retrying loudly. + log_warn " admin-create-user failed; user may already exist — continuing" + fi + + # Unconditional: moves the user to CONFIRMED from any state. Without this, + # Hosted-UI login hits FORCE_CHANGE_PASSWORD and the load test cannot log in. + # Goes through a 0600 temp file so the password never appears in argv. + set_permanent_password "${USER_POOL_ID}" "${username}" "${password}" + + # The app keys everything on the Cognito `sub` claim + # (cognito_jwt_validator.py: user_id=payload["sub"]), so the override must + # be written against the sub, not the username. + user_id="$(aws cognito-idp admin-get-user \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${username}" \ + --query "UserAttributes[?Name=='sub'].Value | [0]" \ + --output text \ + --region "${CDK_AWS_REGION}")" + + if [ -z "${user_id}" ] || [ "${user_id}" = "None" ]; then + log_error " Could not read the 'sub' attribute for ${username}; skipping override" + continue + fi + + override_id="loadtest-${RUN_ID}-${index}" + put_unlimited_override \ + "${QUOTA_TABLE}" "${override_id}" "${user_id}" \ + "${VALID_FROM}" "${VALID_UNTIL}" "${RUN_ID}" + + entries+=("$(json_entry "${username}" "${password}" "${user_id}" "${override_id}")") + created=$((created + 1)) + log_success " ready (sub ${user_id:0:8}…, override ${override_id})" +done + +if [ "${created}" -eq 0 ]; then + log_error "No users were provisioned. Manifest left empty: ${MANIFEST}" + exit 1 +fi + +{ + # jq -s slurps the individual entry objects into a single array, so the + # manifest is valid JSON without hand-assembling commas. + printf '%s\n' "${entries[@]}" | jq -s '.' +} > "${MANIFEST}" +chmod 600 "${MANIFEST}" + +cat </api + +Then clean up — the quota overrides are live cost controls that are currently OFF: + + scripts/load-test/teardown.sh --manifest "${MANIFEST}" + +EOF diff --git a/scripts/load-test/teardown.sh b/scripts/load-test/teardown.sh new file mode 100755 index 000000000..f1ef157a6 --- /dev/null +++ b/scripts/load-test/teardown.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Remove the Cognito users and quota overrides created by provision.sh. +# +# scripts/load-test/teardown.sh --manifest ~/.config/agentcore-load/users-.json +# +# NOT RUN BY CI. Deletes users from a live pool and removes quota-override rows. +# +# Run this. A forgotten 'unlimited' override is a cost control that is silently +# switched off for a real user id, and leftover load-test users keep working +# credentials against your platform. +# +# Safety rail: every username in the manifest must start with the load-test +# prefix, and the check happens before anything is deleted. A hand-edited or +# swapped manifest therefore cannot be used to delete real accounts. +# +# Required environment: +# CDK_PROJECT_PREFIX resolves SSM parameters +# CDK_AWS_REGION (or AWS_REGION) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=./lib.sh +source "${SCRIPT_DIR}/lib.sh" + +MANIFEST="" +DRY_RUN=false +ASSUME_YES=false +KEEP_MANIFEST=false + +usage() { + cat <<'EOF' +Usage: teardown.sh --manifest PATH [options] + + --manifest PATH Manifest written by provision.sh (required) + --keep-manifest Do not delete the manifest afterwards + --dry-run Print the plan; make no changes + --yes Skip the confirmation prompt + -h, --help This message +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --manifest) MANIFEST="$2"; shift 2 ;; + --keep-manifest) KEEP_MANIFEST=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --yes) ASSUME_YES=true; shift ;; + -h|--help) usage; exit 0 ;; + *) log_error "Unknown option: $1"; usage; exit 1 ;; + esac +done + +if [ -z "${MANIFEST}" ]; then + log_error "--manifest is required." + usage + exit 1 +fi +if [ ! -f "${MANIFEST}" ]; then + log_error "Manifest not found: ${MANIFEST}" + exit 1 +fi + +require_env + +# --------------------------------------------------------------------------- +# Parse and validate the manifest before touching anything. +# +# The prefix check is the important part: it runs across the whole manifest and +# refuses the entire file on any violation, so a bad manifest cannot delete a +# subset of real users before failing. +# --------------------------------------------------------------------------- +if ! jq -e 'type == "array" and length > 0' "${MANIFEST}" >/dev/null 2>&1; then + log_error "Manifest is not a non-empty JSON array: ${MANIFEST}" + log_error "Nothing was deleted." + exit 1 +fi + +OFFENDING="$(jq -r --arg prefix "${USERNAME_PREFIX}" ' + [.[] | (.username // "") + | select(startswith($prefix) | not)] + | join(", ") +' "${MANIFEST}")" + +if [ -n "${OFFENDING}" ]; then + log_error "Refusing to act on this manifest. These entries do not start with '${USERNAME_PREFIX}':" + log_error " ${OFFENDING}" + log_error "Only files produced by provision.sh can be torn down. Nothing was deleted." + exit 1 +fi + +# Tab-separated: usernames and override ids are constrained to [A-Za-z0-9-] +# plus the prefix, so neither field can contain a tab. +mapfile -t ROWS < <(jq -r '.[] | [.username, (.override_id // "")] | @tsv' "${MANIFEST}") + +if [ ${#ROWS[@]} -eq 0 ]; then + log_error "Manifest yielded no usable entries: ${MANIFEST}" + exit 1 +fi + +USER_POOL_ID="$(resolve_user_pool_id)" +QUOTA_TABLE="$(resolve_quota_table)" + +cat <}" +done +echo + +if [ "${DRY_RUN}" = true ]; then + log_info "--dry-run: no changes made." + exit 0 +fi + +confirm_or_exit "${ASSUME_YES}" "Delete ${#ROWS[@]} user(s) and their quota overrides?" + +# --------------------------------------------------------------------------- +# Delete. Overrides first: if the run is interrupted, the worse leftover to +# have is a live user with a disabled cost limit, so remove the limit bypass +# before the account that could use it. +# --------------------------------------------------------------------------- +failures=0 + +for row in "${ROWS[@]}"; do + username="${row%%$'\t'*}" + override_id="${row##*$'\t'}" + + log_info "${username}" + + if [ -n "${override_id}" ]; then + if delete_override "${QUOTA_TABLE}" "${override_id}"; then + log_success " override ${override_id} removed" + else + log_error " FAILED to remove override ${override_id} — cost limit still bypassed" + failures=$((failures + 1)) + fi + fi + + if aws cognito-idp admin-delete-user \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${username}" \ + --region "${CDK_AWS_REGION}" \ + --no-cli-pager >/dev/null 2>&1; then + log_success " user deleted" + else + # Already gone is the common case on a re-run; report it without + # failing the whole teardown. + log_warn " user not deleted (already absent?)" + fi +done + +if [ "${failures}" -gt 0 ]; then + log_error "${failures} override(s) could not be removed. Manifest kept: ${MANIFEST}" + log_error "Re-run teardown, or remove them from the admin Quota Overrides page." + exit 1 +fi + +if [ "${KEEP_MANIFEST}" = true ]; then + log_warn "Manifest kept at ${MANIFEST} — it still contains plaintext passwords." +else + rm -f "${MANIFEST}" + log_info "Manifest deleted." +fi + +log_success "Teardown complete." diff --git a/scripts/load-test/watch-tpm.sh b/scripts/load-test/watch-tpm.sh new file mode 100755 index 000000000..fc752f48e --- /dev/null +++ b/scripts/load-test/watch-tpm.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# Watch Bedrock TPM quota consumption while a load test runs. +# +# scripts/load-test/watch-tpm.sh --model-id global.anthropic.claude-sonnet-5 +# +# WHY THIS IS A SEPARATE SCRIPT +# +# tests/load/ deliberately has no AWS credentials — provisioning is the only +# part of this system that touches your account, and that boundary is worth +# keeping. So the quota readout runs alongside the load generator rather than +# inside it. It is read-only: CloudWatch GetMetricStatistics and Service Quotas +# reads, nothing else. +# +# WHAT IT TELLS YOU THAT THE LOCUST OUTPUT CANNOT +# +# * Quota headroom. Throttling arrives as opaque 5xx/error turns in Locust. +# Here you see the approach to the limit before it bites, which is the +# difference between "we found the ceiling" and "the run mysteriously broke". +# * Whether the run is token-REPRESENTATIVE. It divides quota tokens by +# invocations to give implied tokens/turn. Production measures ~26,700; the +# default (tool-less, short-prompt) load profile produces ~1,920. If this +# column reads two thousand, the run is not testing what you think, and no +# amount of user count will make it so. +# +# WHY 5-MINUTE WINDOWS +# +# Single-minute EstimatedTPMQuotaUsage datapoints are not trustworthy: one +# observed minute in production reported 546,206 quota tokens against a single +# invocation, which exceeds the model's own 200k context window. Whether that is +# stream-window attribution or cache-write accounting, it makes per-minute peaks +# unusable. Five-minute sums divided by five are stable. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=./lib.sh +source "${SCRIPT_DIR}/lib.sh" + +MODEL_ID="" +QUOTA_CODE="" +QUOTA_VALUE="" +INTERVAL=60 +WINDOW=300 + +usage() { + cat <<'EOF' +Usage: watch-tpm.sh --model-id ID [options] + + --model-id ID CloudWatch ModelId dimension. This is the inference profile, + not the bare model, e.g.: + global.anthropic.claude-sonnet-5 (production default) + us.anthropic.claude-haiku-4-5-20251001-v1:0 (dev default) + --quota-code CODE Service Quotas code for that model's TPM limit. Discovered + automatically when omitted; pass explicitly if discovery is + ambiguous (e.g. L-DD84E5CA for Sonnet 5 global). + --quota N Skip the Service Quotas lookup and use this limit. + --interval N Seconds between samples (default 60). + --window N Metric window in seconds; must be a multiple of 60 + (default 300). + -h, --help This message + +Ctrl-C to stop. Read-only; makes no changes. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --model-id) MODEL_ID="$2"; shift 2 ;; + --quota-code) QUOTA_CODE="$2"; shift 2 ;; + --quota) QUOTA_VALUE="$2"; shift 2 ;; + --interval) INTERVAL="$2"; shift 2 ;; + --window) WINDOW="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) log_error "Unknown option: $1"; usage; exit 1 ;; + esac +done + +if [ -z "${MODEL_ID}" ]; then + log_error "--model-id is required." + usage + exit 1 +fi +for pair in "INTERVAL:${INTERVAL}" "WINDOW:${WINDOW}"; do + name="${pair%%:*}" + value="${pair#*:}" + if ! [[ "${value}" =~ ^[0-9]+$ ]] || [ "${value}" -lt 1 ]; then + log_error "--${name,,} must be a positive integer (got '${value}')." + exit 1 + fi +done +if [ $((WINDOW % 60)) -ne 0 ]; then + log_error "--window must be a multiple of 60 (got ${WINDOW})." + exit 1 +fi + +# This script does not resolve SSM parameters, so it needs credentials and a +# region but not a project prefix. Set a placeholder so require_env's shared +# check passes without inventing a prefix requirement that means nothing here. +CDK_PROJECT_PREFIX="${CDK_PROJECT_PREFIX:-n/a}" +require_env + +# --------------------------------------------------------------------------- +# Resolve the quota limit +# --------------------------------------------------------------------------- + +# Turn an inference-profile id into the label Service Quotas uses. +# global.anthropic.claude-sonnet-5 -> 'sonnet 5' +# us.anthropic.claude-haiku-4-5-20251001-v1:0 -> 'haiku 4.5' +# The digit-hyphen-digit rule matters: quota names spell versions with a dot +# ('Haiku 4.5') while model ids use hyphens, so a naive tr would produce +# 'haiku 4 5' and match nothing. +_quota_label_for_model() { + printf '%s' "$1" \ + | sed -E ' + s/^(global|us|eu|apac)\.// + s/^anthropic\.claude-// + s/-[0-9]{8}-v[0-9]+:[0-9]+$// + s/([0-9])-([0-9])/\1.\2/g + ' \ + | tr '-' ' ' +} + +discover_quota_code() { + local label scope matches count + label="$(_quota_label_for_model "${MODEL_ID}")" + + # The id prefix selects the quota scope, and there is one of each per model: + # 'global.' bills against the Global cross-region limit, 'us.'/'eu.'/'apac.' + # against the plain cross-region one. Without this both match and the user is + # asked to disambiguate something the model id already stated. + case "${MODEL_ID}" in + global.*) scope="global cross-region" ;; + *) scope="cross-region" ;; + esac + + log_info "Discovering TPM quota for '${label}' (${scope})..." + + matches="$(aws service-quotas list-service-quotas \ + --service-code bedrock \ + --max-items 500 \ + --region "${CDK_AWS_REGION}" \ + --output json 2>/dev/null \ + | jq -r --arg label "${label}" --arg scope "${scope}" ' + .Quotas[] + | . as $q + | ($q.QuotaName | ascii_downcase) as $name + | select($name | contains("tokens per minute")) + | select($name | contains($label | ascii_downcase)) + | select( + if $scope == "global cross-region" + then ($name | startswith("global cross-region")) + else ($name | startswith("cross-region")) + end + ) + | "\($q.QuotaCode)\t\($q.QuotaName)\t\($q.Value)"' || echo "")" + + count="$(printf '%s' "${matches}" | grep -c . || true)" + if [ "${count}" -eq 0 ]; then + log_error "No '${scope} ... tokens per minute' quota matched label '${label}'." + log_error "Pass --quota-code explicitly, or --quota N to skip the lookup." + exit 1 + fi + if [ "${count}" -gt 1 ]; then + log_error "Label '${label}' matched ${count} quotas; pass --quota-code to disambiguate:" + printf '%s\n' "${matches}" | while IFS=$'\t' read -r code name value; do + log_error " ${code} ${name} = ${value}" + done + exit 1 + fi + + QUOTA_CODE="$(printf '%s' "${matches}" | cut -f1)" + QUOTA_VALUE="$(printf '%s' "${matches}" | cut -f3)" + log_info "Using ${QUOTA_CODE}: $(printf '%s' "${matches}" | cut -f2)" +} + +if [ -z "${QUOTA_VALUE}" ]; then + if [ -z "${QUOTA_CODE}" ]; then + discover_quota_code + else + QUOTA_VALUE="$(aws service-quotas get-service-quota \ + --service-code bedrock \ + --quota-code "${QUOTA_CODE}" \ + --query "Quota.Value" \ + --output text \ + --region "${CDK_AWS_REGION}" 2>/dev/null || echo "")" + if [ -z "${QUOTA_VALUE}" ] || [ "${QUOTA_VALUE}" = "None" ]; then + log_error "Could not read quota ${QUOTA_CODE}. Pass --quota N instead." + exit 1 + fi + fi +fi + +# The APPLIED value is what throttles you. Report when it equals the AWS +# default, because that means no increase has ever landed — the single most +# common reason a capacity plan silently assumes headroom it does not have. +if [ -n "${QUOTA_CODE}" ]; then + default_value="$(aws service-quotas get-aws-default-service-quota \ + --service-code bedrock \ + --quota-code "${QUOTA_CODE}" \ + --query "Quota.Value" \ + --output text \ + --region "${CDK_AWS_REGION}" 2>/dev/null || echo "")" + if [ -n "${default_value}" ] && [ "${default_value}" = "${QUOTA_VALUE}" ]; then + log_warn "Applied quota equals the AWS default (${QUOTA_VALUE}) — no increase is in effect." + fi +fi + +# --------------------------------------------------------------------------- +# Poll +# --------------------------------------------------------------------------- + +_metric_sum() { + # Most recent complete datapoint for a metric over the window. + local metric="$1" start end + end="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + start="$(date -u -d "-$((WINDOW * 2)) seconds" +%Y-%m-%dT%H:%M:%SZ)" + + aws cloudwatch get-metric-statistics \ + --namespace AWS/Bedrock \ + --metric-name "${metric}" \ + --dimensions "Name=ModelId,Value=${MODEL_ID}" \ + --start-time "${start}" \ + --end-time "${end}" \ + --period "${WINDOW}" \ + --statistics Sum \ + --region "${CDK_AWS_REGION}" \ + --output json 2>/dev/null \ + | jq -r '[.Datapoints[]] | sort_by(.Timestamp) | last | .Sum // 0' 2>/dev/null \ + || echo "0" +} + +log_info "Watching ${MODEL_ID} against a ${QUOTA_VALUE} tokens/min quota." +log_info "Sampling every ${INTERVAL}s over ${WINDOW}s windows. Ctrl-C to stop." +echo +printf '%-10s %14s %8s %10s %14s %s\n' \ + "TIME" "TOKENS/MIN" "% QUOTA" "TURNS/MIN" "TOKENS/TURN" "STATUS" + +while true; do + quota_tokens="$(_metric_sum EstimatedTPMQuotaUsage)" + invocations="$(_metric_sum Invocations)" + + read -r tpm pct tps per_turn status < 0) ? (tpm / quota) * 100 : 0 + per_turn = (invs > 0) ? tokens / invs : 0 + + # Thresholds mirror the leading-vs-lagging split in the observability + # doc: act on the approach, not on the throttle. + status = "ok" + if (pct >= 90) status = "CRITICAL-throttling-imminent" + else if (pct >= 70) status = "WARN-request-increase-now" + else if (pct >= 50) status = "watch" + + # Flag an unrepresentative workload. Production is ~26,700 tokens/turn; + # anything under ~10,000 means tools are off or prompts are trivial, and + # the TPM result does not generalise. + if (invs > 0 && per_turn < 10000) status = status "/UNREPRESENTATIVE" + + printf "%.0f %.1f %.1f %.0f %s", tpm, pct, tps, per_turn, status + }') +EOF + + printf '%-10s %14s %7s%% %10s %14s %s\n' \ + "$(date -u +%H:%M:%S)" "${tpm}" "${pct}" "${tps}" "${per_turn}" "${status}" + + sleep "${INTERVAL}" +done diff --git a/scripts/local-dev/kb-cap-benchmark.py b/scripts/local-dev/kb-cap-benchmark.py new file mode 100644 index 000000000..9998cde85 --- /dev/null +++ b/scripts/local-dev/kb-cap-benchmark.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Answer-quality A/B for the managed context cap — the measurement task 16.1 needs. + + cd backend + uv run python ../scripts/local-dev/kb-cap-benchmark.py ast-1a90784a7f18 \ + "is CS434 a required course or an elective?" + + # a scorable question set, one query per line (blank / #-comment lines skipped) + uv run python ../scripts/local-dev/kb-cap-benchmark.py ast-1a90784a7f18 -f queries.txt + +WHAT THIS ANSWERS, AND WHY kb-compare-engines.py IS NOT ENOUGH +`kb-compare-engines.py` shows which chunks clear the 2,000-char cap. It measures +the RETRIEVER. It cannot show the consequence HANDOFF §5.40 is actually about: +the model gave a *materially wrong answer* (a Major-Core course called an +elective) even though retrieval ranked the right chunk first, because the cap +discarded the neighbours that carried the section header. Answer quality is not +retrieval quality — §5.40's own "earlier demo advice was wrong" note is exactly +this mistake. So this harness runs the whole path: retrieve → augment → MODEL, +and prints the answer at each cap side by side. + +THE EXPERIMENT IS CONTROLLED +Everything is held identical across the two arms except ``max_context_length``: +same assistant, same retrieved chunks (retrieval runs once), same system prompt +(the assistant's own instructions), same model, temperature 0. The only thing +that changes is how many of the retrieved chunks survive the cap and reach the +model. It reuses the REAL production ``rag_service.augment_prompt_with_context``, +so a passing result here is a statement about the code that ships, not a proxy. + +DEFAULT CAPS: 2000 (today) vs 8000 (the evaluation's §13.6 sizing — the point at +which all five managed chunks fit, at ~966 extra input tokens/turn). Override +with --caps. + +READ + INFERENCE ONLY: issues retrievals, one DynamoDB get for the assistant's +instructions, and Bedrock Converse calls. Writes nothing, mutates nothing. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT / "backend" / "src")) + +from dotenv import load_dotenv # noqa: E402 + +load_dotenv(_REPO_ROOT / "backend" / "src" / ".env", override=True) + +TOP_K = 5 +DEFAULT_CAPS = (2000, 8000) +# Held constant across both arms — the cap is the only variable. Sonnet is a +# capable default; override with --model to match a specific assistant. +DEFAULT_MODEL = "us.anthropic.claude-sonnet-4-5-20250929-v1:0" +MAX_ANSWER_TOKENS = 1024 + + +def _fits_count(chunk_texts: list[str], cap: int) -> int: + """How many whole chunks clear the cap, mirroring augment's accumulation.""" + running = 0 + fit = 0 + for i, text in enumerate(chunk_texts, 1): + block = f"[Context {i}]\n{text.strip()}\n" + if running + len(block) > cap: + break + running += len(block) + fit += 1 + return fit + + +def _assistant_instructions(assistant_id: str) -> str: + import boto3 + + table_name = os.environ.get("DYNAMODB_ASSISTANTS_TABLE_NAME") + if not table_name: + print(" ⚠️ DYNAMODB_ASSISTANTS_TABLE_NAME not set; using an empty system prompt") + return "" + region = os.environ.get("AWS_REGION", "us-west-2") + table = boto3.resource("dynamodb", region_name=region).Table(table_name) + item = table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "METADATA"}).get("Item") or {} + return item.get("instructions") or "" + + +def _answer(model_id: str, system_prompt: str, augmented_message: str) -> str: + import boto3 + + region = os.environ.get("AWS_REGION", "us-west-2") + client = boto3.client("bedrock-runtime", region_name=region) + kwargs = { + "modelId": model_id, + "messages": [{"role": "user", "content": [{"text": augmented_message}]}], + "inferenceConfig": {"maxTokens": MAX_ANSWER_TOKENS, "temperature": 0.0}, + } + if system_prompt.strip(): + kwargs["system"] = [{"text": system_prompt}] + resp = client.converse(**kwargs) + parts = resp["output"]["message"]["content"] + return "".join(p.get("text", "") for p in parts).strip() + + +async def run(assistant_id: str, queries: list[str], caps: tuple[int, ...], model_id: str, + instructions_file: str | None = None) -> int: + from apis.shared.assistants.rag_service import augment_prompt_with_context + from apis.shared.kb_backend.resolver import load_record, resolve_backend, resolve_engine_for + + record = load_record(assistant_id) + engine = resolve_engine_for(assistant_id, record=record) + print(f"assistant : {assistant_id}") + print(f"retrievalEngine : {engine}") + print(f"model : {model_id}") + print(f"caps : {', '.join(str(c) for c in caps)}") + if engine != "managed": + print( + "\n⚠️ This assistant is not on the managed engine, so the cap A/B is not\n" + " meaningful here — legacy chunks are small and the cap rarely bites.\n" + ) + + backend = resolve_backend(assistant_id, record=record) + if instructions_file: + instructions = Path(instructions_file).read_text() + else: + instructions = _assistant_instructions(assistant_id) + + for query in queries: + print("\n" + "=" * 100) + print(f"QUERY: {query!r}") + print("=" * 100) + + chunks = await backend.search(assistant_id, query, TOP_K) + texts = [c.text for c in chunks] + if not chunks: + print(" (retrieval returned nothing — skipping)") + continue + + sizes = ", ".join(str(len(t)) for t in texts) + print(f" retrieved {len(chunks)} chunks, sizes (chars): {sizes}") + + for cap in caps: + fit = _fits_count(texts, cap) + chunk_dicts = [{"text": t} for t in texts] + augmented = augment_prompt_with_context( + user_message=query, context_chunks=chunk_dicts, max_context_length=cap + ) + try: + answer = _answer(model_id, instructions, augmented) + except Exception as exc: # noqa: BLE001 — diagnostic harness + answer = f"[model call RAISED {type(exc).__name__}: {str(exc)[:200]}]" + print(f"\n ── cap={cap} ({fit}/{len(chunks)} chunks reach the model)") + for line in answer.splitlines() or [""]: + print(f" {line}") + + print( + "\n\nReading this: the two answers differ ONLY because a different number of the\n" + "same retrieved chunks reached the model. If cap=2000 is wrong and cap=8000 is\n" + "right, that is the §5.40 defect and its fix, measured end to end. Score each\n" + "answer against ground truth you hold (this harness does not judge for you).\n" + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("assistant_id") + parser.add_argument("query", nargs="*", help="a single query (words joined); or use -f") + parser.add_argument("-f", "--file", help="file of queries, one per line") + parser.add_argument("--caps", default=",".join(str(c) for c in DEFAULT_CAPS), help="comma-separated char caps") + parser.add_argument("--model", default=DEFAULT_MODEL, help="Bedrock model id") + parser.add_argument("--instructions-file", default=None, help="file whose contents become the system prompt (else read the assistant's METADATA row)") + args = parser.parse_args() + + if args.file: + queries = [ + ln.strip() for ln in Path(args.file).read_text().splitlines() if ln.strip() and not ln.startswith("#") + ] + elif args.query: + queries = [" ".join(args.query)] + else: + parser.error("give a query or -f FILE") + + caps = tuple(int(c) for c in args.caps.split(",") if c.strip()) + return asyncio.run(run(args.assistant_id, queries, caps, args.model, args.instructions_file)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/load/README.md b/tests/load/README.md new file mode 100644 index 000000000..c5fbeb8f7 --- /dev/null +++ b/tests/load/README.md @@ -0,0 +1,233 @@ +# Load tests (Locust) + +Simulates signed-in browser users against the real chat path: Cognito Hosted UI +login → `POST /chat/stream` → SSE, with client-side time-to-first-token. + +> **This spends real money.** Every chat turn is a live Bedrock invocation +> billed to your account and counted against the acting user's quota. A 50-user +> run at one turn per 10s is ~300 turns/minute. Start small, watch the cost +> dashboard, and never point this at production without agreeing a budget +> first. + +## What it tests, and why this path + +`POST /chat/stream` is the only path real users take. It is cookie-only — +Bearer callers were retired in the BFF migration (`apis/shared/auth/dependencies.py`) +— so the test performs a full OAuth authorization-code login rather than +minting a token. Each turn traverses CloudFront → ALB → app-api on Fargate → +inference-api on the AgentCore Runtime, with app-api holding a connection open +relaying SSE for the whole turn. + +That last detail is the point. Concurrency is bounded by held-open connections, +not by request rate, so **RPS is the wrong number to watch**. Watch concurrent +users, time-to-first-token, and error rate. + +The API-key path (`/chat/api-converse`) is deliberately *not* exercised here. +It is a minor feature, and it is rate-limited to 60 requests per 60 seconds per +key (`apis/shared/rate_limit.py`), so it cannot represent platform load. + +## Setup + +Runs in the devcontainer. From `tests/load/`: + +```bash +uv sync +``` + +Deliberately a separate project from `backend/` — Locust is a testing tool, not +an application dependency, and putting it in `backend/pyproject.toml` would +land it in `backend/uv.lock` and in the app-api image's dependency resolution. + +## Configuration + +| Variable | Required | Meaning | +|---|---|---| +| `--host` (CLI flag) | yes | app-api origin, e.g. `https://chat.example.edu/api`. **Must be https** — see below. | +| `AGENTCORE_LOAD_COGNITO_DOMAIN` | yes | Hosted UI domain, e.g. `https://your-prefix.auth.us-west-2.amazoncognito.com` | +| `AGENTCORE_LOAD_USERS_FILE` | one of | JSON array of `{"username", "password"}` — the provisioning script's output | +| `AGENTCORE_LOAD_USERNAME` / `_PASSWORD` | one of | Single user, for a smoke run | +| `AGENTCORE_LOAD_MODEL_ID` | no | Omit for the system default | +| `AGENTCORE_LOAD_PROVIDER` | no | `bedrock`, `openai`, `gemini` | +| `AGENTCORE_LOAD_ENABLED_TOOLS` | no | Comma-separated. Empty (default) = no tools | +| `AGENTCORE_LOAD_TURNS_PER_CONVERSATION` | no | Default 3 | +| `AGENTCORE_LOAD_PROMPTS_FILE` | no | One prompt per line; `#` lines are comments. Overrides the built-ins | +| `AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE` | no | Let simulated users share Cognito identities. Off by default — see below | + +**`--host` must be https.** The session and CSRF cookies are `__Host-` +prefixed, hence `Secure`-only. Over plain http `requests` silently drops them +and every chat request 401s — which reads as a broken backend. `validate_host` +fails fast instead. + +**Leave tools disabled** unless you are specifically testing them. Tool calls +add multi-second, high-variance latency that swamps the signal. + +## Running + +```bash +export AGENTCORE_LOAD_COGNITO_DOMAIN="https://your-prefix.auth.us-west-2.amazoncognito.com" +export AGENTCORE_LOAD_USERS_FILE="$HOME/.config/agentcore-load/users.json" + +# Web UI at :8089 +locust -f locustfile.py --host https://chat.example.edu/api + +# Headless, 10 users, 5 minutes +locust -f locustfile.py --host https://chat.example.edu/api \ + --headless --users 10 --spawn-rate 1 --run-time 5m + +# Free control run — no Bedrock spend +locust -f locustfile_readonly.py --host https://chat.example.edu/api \ + --headless --users 50 --spawn-rate 5 --run-time 5m +``` + +One worker per core with `--processes -1`. You will not need it soon: the +generator is never the bottleneck on a path where each user holds a stream open +for seconds. In the devcontainer, mind the memory cap and keep `--processes` +at or below 4. + +## One Cognito identity per simulated user + +Each simulated user is assigned its **own** credential from the manifest, and a +run that asks for more users than the pool holds **refuses to start**: + +``` +Refusing to start: 50 simulated users requested but only 3 credential(s) +available. Each user needs its own Cognito identity or the run measures +DynamoDB partition contention that real users would not create. +``` + +This is deliberately a hard failure rather than a warning. Simulated users +sharing an identity also share its `user_id`, which means one DynamoDB partition +for session and cost writes, one quota counter, and one memory namespace. The +resulting latency partly measures the test's own key collisions — a +plausible-looking wrong answer, which is worse than an obviously broken run. + +Provision to match your target concurrency: + +```bash +scripts/load-test/provision.sh --users 300 --quota-days 1 +``` + +`AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE=1` restores the old round-robin for cases +where sharing genuinely does not matter — a smoke run, or measuring the login +path alone. + +Under `--processes`, each worker takes a stride of the pool (`pool[i::n]`) so two +workers never deal the same account. That needs at least one credential per +worker. + +## Campus-scale runs + +The default profile is cheap on purpose, and that makes it **useless for +capacity planning**. Measured against the real production deployment: + +| Per turn | production | default profile | +|---|---|---| +| input tokens | 384 | 1,781 | +| cache-read tokens | 24,926 | 0 | +| output tokens | 1,372 | 139 | +| **total counted against quota** | **~26,700** | **~1,920** | + +Cache-read tokens count against the Bedrock TPM quota. So the default profile +understates consumption ~14× per turn, and a run that passes comfortably can +correspond to a production workload that throttles. + +Use the representative profile for anything you intend to draw conclusions from: + +```bash +source tests/load/profiles/campus-representative.env +``` + +It enables a realistic tool set (tool schemas are what create the cached +prefix), uses long-form prompts, and deepens conversations. Note the prompts are +answerable *without* tool calls on purpose — the tools are there to reproduce +prompt size, not to fire Canvas, Salesforce, and Brave Search at 300× concurrency. + +### The classroom burst + +The shape that actually threatens this platform is not 1300 users arriving +gradually; it is one instructor saying "ask the assistant about X" and 300 +students submitting inside thirty seconds. + +```bash +source tests/load/profiles/campus-representative.env +export AGENTCORE_LOAD_TURNS_PER_CONVERSATION=2 +locust -f locustfile_classroom.py --host https://your-domain/api --headless +``` + +`--users`, `--spawn-rate` and `--run-time` are ignored; `ClassroomBurstShape` +drives the run and ends it. It fires two bursts — the first against cold +infrastructure (2 Fargate tasks, empty prompt cache), the second against warm — +because "survives a class following another class" and "survives the 9am class" +are different answers. Tunable via `AGENTCORE_LOAD_BURST_*`; see `shapes.py`. + +### Watch the quota while it runs + +Throttling reaches Locust as opaque failed turns. Run this alongside any chat +scenario to see the approach to the limit instead: + +```bash +scripts/load-test/watch-tpm.sh --model-id global.anthropic.claude-sonnet-5 +``` + +It also prints implied **tokens/turn**, which is how you confirm the run is +representative rather than trusting that it is. If that column reads ~2,000 +instead of ~26,700, the profile is not loaded and the TPM result will not +generalise. + +## Reading the results + +Four row types appear, and they measure different things: + +| Row | What it is | +|---|---| +| `POST /chat/stream` | **Time to response headers only** — the request is streamed, so this is not turn duration. `response_length` is 0 for streamed requests; ignore the byte columns. | +| `SSE chat: time to first token` | Login-independent responsiveness. The number that tracks user experience. | +| `SSE chat: full turn` | Whole turn, first byte to `done`. `response_length` is the character count. | +| `GET /auth/login`, `POST cognito /login`, … | Login hops. Login degrading under load is a real finding, so they are measured, not hidden. | + +Expect `full turn` percentiles in the tens of seconds and do not read that as +failure. Measured over 14 days in dev, a healthy turn averages 3.0–4.5s with +daily maxima of 16.7s, 16.9s and 24.4s. A sudden *drop* can mean turns are +failing early. + +A turn that returns HTTP 200 can still fail inside the stream — quota blocks +and model errors arrive as `message_stop` with `stopReason: "error"`. Those are +recorded as failures on `SSE chat: full turn`, not on the HTTP row. + +## Provisioning users + +Not done here. This process has no AWS credentials by design. It consumes a +credential manifest produced separately, because creating users, granting quota +overrides and tearing them down mutates live shared state and needs to be +gated and audited on its own terms. + +Two things to know when provisioning: + +- **Users must have permanent passwords.** `FORCE_CHANGE_PASSWORD` blocks + scripted login; the run fails at `POST cognito /login` with a clear message. +- **Quota overrides are usually required.** Sustained turns will trip per-user + cost limits, after which you are measuring the quota-enforcement path rather + than the chat path. Grant time-limited `unlimited` overrides and **remove them + afterwards** — a forgotten override is a disabled cost control. + +Fewer credentials than simulated users is fine: each Locust user keeps its own +cookie jar, so several can log in as one Cognito account and get independent +BFF sessions. Credentials are handed out round-robin. The distortion is that +quota, memory and session history concentrate on those accounts, so keep the +pool wide enough that per-user state is not the bottleneck you accidentally +find. + +## Limitations + +- **Cognito Hosted UI only.** Login submits the server-rendered form. If a + deployment uses managed login (branding v2) with a client-rendered form, or + an IdP with MFA or conditional access, scripted login will fail — with a + diagnostic naming the forms it parsed. Use a dedicated Cognito provider for + load-test users. +- **`requests`-based, not `FastHttpUser`.** SSE needs `iter_lines`, which + `FastHttpUser` does not offer. Lower per-worker ceiling, irrelevant on this + path. +- **`-f -` will not work** for distributed runs, because this is a package + rather than a single file. Ship the directory to workers (volume mount or + image layer) instead. +- **No file uploads, voice, or tool-heavy turns.** Text turns only. diff --git a/tests/load/agentcore_load/__init__.py b/tests/load/agentcore_load/__init__.py new file mode 100644 index 000000000..7f3b86b52 --- /dev/null +++ b/tests/load/agentcore_load/__init__.py @@ -0,0 +1,17 @@ +"""Locust load tests for the AgentCore platform. + +See ``tests/load/README.md``. Start with the cost warning. +""" + +from .config import ConfigError, Credential, LoadConfig, load_config, validate_host +from .users import AuthenticatedUser, ChatUser + +__all__ = [ + "AuthenticatedUser", + "ChatUser", + "ConfigError", + "Credential", + "LoadConfig", + "load_config", + "validate_host", +] diff --git a/tests/load/agentcore_load/auth.py b/tests/load/agentcore_load/auth.py new file mode 100644 index 000000000..fa0057d22 --- /dev/null +++ b/tests/load/agentcore_load/auth.py @@ -0,0 +1,288 @@ +"""Establish a real BFF session by driving the Cognito Hosted UI. + +Why this is necessary: ``POST /chat/stream`` is cookie-only. Bearer callers +were retired in the BFF migration (see the notes in +``apis/shared/auth/dependencies.py``), so there is no token you can mint with +``initiate-auth`` and present directly — the session cookie is only issued by +``GET /auth/callback`` in exchange for an authorization code. Getting that code +means completing the Hosted UI form the way a browser would. + +The flow, mirroring ``apis/app_api/auth/bff/routes.py``: + + 1. ``GET {app_api}/auth/login`` -> 302 to Cognito /oauth2/authorize + (sets __Host-bff_oauth_state, + PKCE verifier, OIDC nonce) + 2. ``GET {cognito}/oauth2/authorize?...`` -> Hosted UI login page (HTML form) + 3. ``POST {cognito}{form.action}`` -> 302 to the callback with ?code=&state= + 4. ``GET {app_api}/auth/callback?...`` -> sets __Host-bff_session + __Host-bff_csrf + 5. ``GET {app_api}/auth/session`` -> {user, csrf_token} + +Step 3 is the brittle step: it depends on Cognito's login markup. The parser +below is deliberately generic (find the form with a password field, resubmit +every hidden input) rather than hardcoding field names, and raises a +descriptive error naming what it actually found so a markup change is +diagnosable instead of mysterious. +""" + +from __future__ import annotations + +import logging +from html.parser import HTMLParser +from urllib.parse import urljoin + +from .config import Credential, LoadConfig + +logger = logging.getLogger(__name__) + +# Cognito's own CSRF field on the Hosted UI form. Captured generically as a +# hidden input; named here only for the error message when it is absent. +_COGNITO_CSRF_FIELD = "_csrf" + + +class LoginError(RuntimeError): + """A login attempt failed in a way that is not the system under test's fault.""" + + +class _HtmlForm: + def __init__(self, action: str | None, method: str) -> None: + self.action = action + self.method = method.lower() + self.fields: dict[str, str] = {} + self.password_field: str | None = None + self.text_fields: list[str] = [] + + def __repr__(self) -> str: + return ( + f"_HtmlForm(action={self.action!r}, method={self.method!r}, " + f"fields={sorted(self.fields)}, password_field={self.password_field!r})" + ) + + +class _FormParser(HTMLParser): + """Collect every ``
`` with its inputs. + + Values of password inputs are ignored (they are never pre-filled, and we + do not want a credential echoed into a parsed structure by accident). + """ + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.forms: list[_HtmlForm] = [] + self._current: _HtmlForm | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attr = {key.lower(): (value or "") for key, value in attrs} + + if tag == "form": + self._current = _HtmlForm(action=attr.get("action"), method=attr.get("method", "get")) + self.forms.append(self._current) + return + + if tag != "input" or self._current is None: + return + + name = attr.get("name") + if not name: + return + + input_type = attr.get("type", "text").lower() + if input_type == "password": + self._current.password_field = name + self._current.fields[name] = "" + elif input_type in {"hidden", "text", "email", "tel"}: + self._current.fields[name] = attr.get("value", "") + if input_type != "hidden": + self._current.text_fields.append(name) + + def handle_endtag(self, tag: str) -> None: + if tag == "form": + self._current = None + + +def _find_login_form(html: str) -> _HtmlForm: + parser = _FormParser() + parser.feed(html) + + candidates = [form for form in parser.forms if form.password_field] + if not candidates: + raise LoginError( + "No form with a password input found on the Cognito login page. " + f"Parsed {len(parser.forms)} form(s): {parser.forms!r}. If this " + "deployment uses Cognito's managed login (branding v2) with a " + "client-rendered form, scripted login is not supported — see " + "tests/load/README.md." + ) + return candidates[0] + + +def _pick_username_field(form: _HtmlForm) -> str: + """Choose which field carries the username. + + Prefer an explicitly named field; otherwise fall back to the only + non-hidden text input, which is what the Hosted UI renders. + """ + for candidate in ("username", "email", "signInFormUsername"): + if candidate in form.fields: + return candidate + if len(form.text_fields) == 1: + return form.text_fields[0] + raise LoginError( + "Could not determine the username field on the Cognito login form. " + f"Non-hidden text inputs: {form.text_fields!r}." + ) + + +def establish_bff_session(client, config: LoadConfig, credential: Credential) -> str: + """Log in and return the CSRF token for the new session. + + ``client`` is a Locust ``HttpSession``: a ``requests.Session`` that also + reports timings, so each hop below shows up as its own entry in the stats + table. That is deliberate — login cost is part of what you want to see, and + a login that degrades under load is a real finding. + + On return, ``client``'s cookie jar holds ``__Host-bff_session`` and the + caller should send the returned token in ``X-CSRF-Token`` on every unsafe + request. + """ + authorize_url = _begin_login(client) + login_page = _fetch_login_page(client, authorize_url) + callback_url = _submit_credentials(client, authorize_url, login_page, credential) + _complete_callback(client, callback_url) + return _read_csrf_token(client) + + +def _begin_login(client) -> str: + """GET /auth/login and return the Cognito authorize URL it redirects to.""" + with client.get( + "/auth/login", + allow_redirects=False, + catch_response=True, + name="GET /auth/login", + ) as response: + if response.status_code != 302: + response.failure(f"expected 302, got {response.status_code}") + raise LoginError( + f"GET /auth/login returned {response.status_code}, expected a 302 to " + "the Cognito Hosted UI." + ) + location = response.headers.get("Location", "") + if "/oauth2/authorize" not in location: + response.failure("302 Location is not an /oauth2/authorize URL") + raise LoginError(f"Unexpected login redirect target: {location!r}") + response.success() + return location + + +def _fetch_login_page(client, authorize_url: str) -> str: + """Follow the authorize URL to the Hosted UI and return its HTML. + + Redirects are followed here: if the Cognito session is already warm this + can bounce straight through to the callback, which is a legitimate + outcome that the caller handles by finding no login form. + """ + with client.get( + authorize_url, + catch_response=True, + name="GET cognito /oauth2/authorize", + ) as response: + if response.status_code != 200: + response.failure(f"expected 200, got {response.status_code}") + raise LoginError( + f"Cognito /oauth2/authorize returned {response.status_code}. Check that " + "AGENTCORE_LOAD_COGNITO_DOMAIN matches the deployment's Hosted UI domain." + ) + response.success() + return response.text + + +def _submit_credentials( + client, + authorize_url: str, + login_page: str, + credential: Credential, +) -> str: + """POST the login form and return the callback URL Cognito redirects to.""" + form = _find_login_form(login_page) + if _COGNITO_CSRF_FIELD not in form.fields: + logger.warning( + "Cognito login form has no %r hidden field; submitting anyway. Fields present: %s", + _COGNITO_CSRF_FIELD, + sorted(form.fields), + ) + + username_field = _pick_username_field(form) + payload = dict(form.fields) + payload[username_field] = credential.username + payload[form.password_field] = credential.password + + action_url = urljoin(authorize_url, form.action or "") + + with client.post( + action_url, + data=payload, + allow_redirects=False, + catch_response=True, + name="POST cognito /login", + ) as response: + # A 200 means Cognito re-rendered the form: bad credentials, an + # unconfirmed user, or a forced password change. All are provisioning + # problems, not load findings, so fail loudly rather than retrying. + if response.status_code == 200: + response.failure("Cognito re-rendered the login form (credentials rejected)") + raise LoginError( + f"Cognito rejected the login for {credential.username!r}. The user may " + "need a permanent password (FORCE_CHANGE_PASSWORD blocks scripted login)." + ) + if response.status_code != 302: + response.failure(f"expected 302, got {response.status_code}") + raise LoginError(f"Cognito login POST returned {response.status_code}, expected a 302.") + + location = response.headers.get("Location", "") + if "code=" not in location: + response.failure("302 Location carries no authorization code") + raise LoginError(f"Cognito login redirect has no ?code=: {location!r}") + response.success() + return location + + +def _complete_callback(client, callback_url: str) -> None: + """GET /auth/callback to trade the code for session cookies.""" + with client.get( + callback_url, + allow_redirects=False, + catch_response=True, + name="GET /auth/callback", + ) as response: + # The callback answers 302 to the SPA on success. Following it would + # fetch the Angular bundle from CloudFront on every simulated login, + # which is load the real app only generates once per page visit. + if response.status_code not in (302, 303): + response.failure(f"expected 302, got {response.status_code}") + raise LoginError( + f"GET /auth/callback returned {response.status_code}. The state cookie " + "binding may have failed — check that the run is over https so the " + "__Host- cookies are stored." + ) + response.success() + + +def _read_csrf_token(client) -> str: + """GET /auth/session and return the CSRF token for this session.""" + with client.get("/auth/session", catch_response=True, name="GET /auth/session") as response: + if response.status_code != 200: + response.failure(f"expected 200, got {response.status_code}") + raise LoginError( + f"GET /auth/session returned {response.status_code} directly after a " + "successful callback — the session cookie was not stored." + ) + try: + token = response.json().get("csrf_token") + except ValueError as exc: + response.failure("response was not JSON") + raise LoginError("GET /auth/session did not return JSON.") from exc + + if not token: + response.failure("no csrf_token in response") + raise LoginError("GET /auth/session returned no csrf_token.") + response.success() + return str(token) diff --git a/tests/load/agentcore_load/config.py b/tests/load/agentcore_load/config.py new file mode 100644 index 000000000..03a3f8c51 --- /dev/null +++ b/tests/load/agentcore_load/config.py @@ -0,0 +1,227 @@ +"""Environment-driven configuration for the load suite. + +Everything is read from env vars so the same locustfile runs from a laptop, +the devcontainer, or a container in the load-generator harness without code +changes. Nothing here reads AWS credentials or SSM — resolving the Cognito +domain and provisioning users is the provisioning scripts' job, and this +process deliberately has no AWS permissions. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlparse + + +class ConfigError(RuntimeError): + """Raised when the environment is not usable for a run. + + Always raised before any load is generated. A misconfigured run that + starts and then fails every request looks like a broken *system* rather + than a broken *test*, so these checks are deliberately fail-fast. + """ + + +@dataclass(frozen=True) +class Credential: + """One Cognito user the test can log in as. + + By default each simulated user gets its **own** credential — see + ``CredentialPool`` in ``users.py``. Sharing one is possible but has to be + asked for, because it concentrates every shared user's session writes on a + single DynamoDB partition and a single quota counter, which measures + contention the real workload would not have. + """ + + username: str + password: str + + def __repr__(self) -> str: + # Keep the password out of tracebacks and Locust's error tables. + return f"Credential(username={self.username!r}, password=***)" + + +@dataclass(frozen=True) +class LoadConfig: + """Resolved settings for a run.""" + + cognito_domain_url: str + credentials: list[Credential] = field(default_factory=list) + + # Chat shape. `model_id`/`provider` of None means "system default", which + # is what the SPA sends when the user has not picked a model. + model_id: str | None = None + provider: str | None = None + # Empty tool list keeps turns fast, cheap and low-variance. Tool calls add + # multi-second, high-variance latency that swamps the signal you are + # usually looking for. Opt in explicitly when testing the tool path. + enabled_tools: list[str] = field(default_factory=list) + + turns_per_conversation: int = 3 + prompts: list[str] = field(default_factory=list) + + # Let simulated users share Cognito identities when the pool is smaller + # than --users. Off by default: sharing is silent and it invalidates + # results rather than merely degrading them (see CredentialPool). + allow_credential_reuse: bool = False + + # Ceiling on a single turn. app-api's proxy gives up at 300s + # (_PROXY_TIMEOUT_SECONDS), so going past that measures nothing new. + turn_timeout_seconds: float = 300.0 + + @property + def cognito_authorize_host(self) -> str: + return urlparse(self.cognito_domain_url).netloc + + +_DEFAULT_PROMPTS = [ + "In two sentences, what is a load test?", + "Name three benefits of caching. Be brief.", + "Summarize the difference between latency and throughput.", + "What does 'p99 latency' mean? One short paragraph.", + "List four common causes of slow API responses.", +] + + +def _require(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise ConfigError( + f"{name} is required. See tests/load/README.md for the full list " + f"of environment variables." + ) + return value + + +def _load_credentials() -> list[Credential]: + """Load the credential pool from a manifest file or a single env pair. + + The manifest is the output of the provisioning script — a JSON array of + ``{"username": ..., "password": ...}``. Passwords are read from disk and + never logged. + """ + manifest_path = os.environ.get("AGENTCORE_LOAD_USERS_FILE", "").strip() + if manifest_path: + path = Path(manifest_path) + if not path.is_file(): + raise ConfigError(f"AGENTCORE_LOAD_USERS_FILE does not exist: {path}") + try: + raw = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + raise ConfigError(f"{path} is not valid JSON: {exc}") from exc + if not isinstance(raw, list) or not raw: + raise ConfigError(f"{path} must contain a non-empty JSON array of users.") + + credentials = [] + for index, entry in enumerate(raw): + if not isinstance(entry, dict): + raise ConfigError(f"{path}[{index}] is not an object.") + username = entry.get("username") + password = entry.get("password") + if not username or not password: + raise ConfigError(f"{path}[{index}] needs both 'username' and 'password'.") + credentials.append(Credential(username=str(username), password=str(password))) + return credentials + + username = os.environ.get("AGENTCORE_LOAD_USERNAME", "").strip() + password = os.environ.get("AGENTCORE_LOAD_PASSWORD", "") + if username and password: + return [Credential(username=username, password=password)] + + raise ConfigError( + "No credentials configured. Set AGENTCORE_LOAD_USERS_FILE to a JSON " + "manifest, or AGENTCORE_LOAD_USERNAME + AGENTCORE_LOAD_PASSWORD for a " + "single user." + ) + + +def _split_list(name: str) -> list[str]: + raw = os.environ.get(name, "").strip() + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def _bool_env(name: str) -> bool: + """Read an opt-in flag. + + Deliberately strict about what counts as true. A typo like + ``ALLOW_CREDENTIAL_REUSE=ture`` should leave the safe default in place + rather than silently enabling the thing it guards. + """ + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _positive_int(name: str, default: int) -> int: + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError as exc: + raise ConfigError(f"{name} must be an integer, got {raw!r}") from exc + if value < 1: + raise ConfigError(f"{name} must be >= 1, got {value}") + return value + + +def load_config() -> LoadConfig: + """Build the config, failing fast on anything unusable.""" + cognito_domain_url = _require("AGENTCORE_LOAD_COGNITO_DOMAIN").rstrip("/") + if not cognito_domain_url.startswith("https://"): + raise ConfigError( + f"AGENTCORE_LOAD_COGNITO_DOMAIN must be an https:// URL (got {cognito_domain_url!r})." + ) + + prompt_file = os.environ.get("AGENTCORE_LOAD_PROMPTS_FILE", "").strip() + if prompt_file: + path = Path(prompt_file) + if not path.is_file(): + raise ConfigError(f"AGENTCORE_LOAD_PROMPTS_FILE does not exist: {path}") + # '#' lines are comments. Without this a documented prompts file would + # silently send its own header to the model as a user turn. + prompts = [ + line.strip() + for line in path.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + if not prompts: + raise ConfigError(f"{path} contains no non-empty, non-comment lines.") + else: + prompts = list(_DEFAULT_PROMPTS) + + return LoadConfig( + cognito_domain_url=cognito_domain_url, + credentials=_load_credentials(), + model_id=os.environ.get("AGENTCORE_LOAD_MODEL_ID", "").strip() or None, + provider=os.environ.get("AGENTCORE_LOAD_PROVIDER", "").strip() or None, + enabled_tools=_split_list("AGENTCORE_LOAD_ENABLED_TOOLS"), + turns_per_conversation=_positive_int("AGENTCORE_LOAD_TURNS_PER_CONVERSATION", 3), + prompts=prompts, + allow_credential_reuse=_bool_env("AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE"), + ) + + +def validate_host(host: str | None) -> str: + """Validate Locust's ``--host`` (the app-api origin). + + The session and CSRF cookies are ``__Host-`` prefixed, which requires + ``Secure``. `requests` will not store or resend a Secure cookie over + plain http, so an http host produces a login that appears to succeed and + then 401s on every chat request — a confusing failure worth catching here. + """ + if not host: + raise ConfigError( + "No --host set. Pass the app-api origin, e.g. " + "`locust --host https://chat.example.edu/api`." + ) + if not host.startswith("https://"): + raise ConfigError( + f"--host must be https:// (got {host!r}). The BFF session cookie is " + "'__Host-' prefixed and therefore Secure-only; over http it is " + "silently dropped and every chat request would 401." + ) + return host.rstrip("/") diff --git a/tests/load/agentcore_load/scenarios/__init__.py b/tests/load/agentcore_load/scenarios/__init__.py new file mode 100644 index 000000000..761e0e423 --- /dev/null +++ b/tests/load/agentcore_load/scenarios/__init__.py @@ -0,0 +1,9 @@ +"""Scenario user classes. + +``chat`` is the primary, expensive path. ``readonly`` is the cheap control. +""" + +from .chat import ChatConversationUser +from .readonly import BrowsingUser + +__all__ = ["BrowsingUser", "ChatConversationUser"] diff --git a/tests/load/agentcore_load/scenarios/chat.py b/tests/load/agentcore_load/scenarios/chat.py new file mode 100644 index 000000000..846a824ac --- /dev/null +++ b/tests/load/agentcore_load/scenarios/chat.py @@ -0,0 +1,56 @@ +"""Primary scenario: a signed-in user holding a multi-turn conversation. + +This is the expensive path and the one that matters. Every turn traverses +CloudFront -> ALB -> app-api (Fargate) -> inference-api on the AgentCore +Runtime, and app-api holds an httpx connection open relaying SSE for the whole +turn. Concurrency here is bounded by that held-open connection rather than by +request rate, which is why a modest number of simulated users can be a +meaningful test and why raw RPS is the wrong thing to watch. + +WARNING: every turn spends real Bedrock tokens. +""" + +from __future__ import annotations + +import random + +from locust import between, task + +from ..users import ChatUser + + +class ChatConversationUser(ChatUser): + """Logs in, then works through conversations turn by turn. + + Turns within a conversation reuse one ``session_id``, so history grows and + each turn carries a larger prompt than the last — the same cost curve real + conversations have, and the thing prompt caching exists to blunt. A test + that sent every message in a fresh session would understate input tokens + and never exercise the cache at all. + """ + + # Reading a reply then typing the next message. Short enough to keep + # pressure on, long enough that the run is not a synthetic hammer. + wait_time = between(5, 15) + + @task + def hold_conversation(self) -> None: + config = self.load_config + if config is None: # on_start stopped this user + return + + session_id = self.new_conversation_id() + prompts = random.sample( + config.prompts, + k=min(config.turns_per_conversation, len(config.prompts)), + ) + + for prompt in prompts: + result = self.chat_turn(session_id, prompt) + if result is None: + # The failure is already recorded. Abandoning the conversation + # rather than pressing on is deliberate: once a turn fails the + # session's history is in an unknown state, and continuing + # would attribute later failures to the wrong cause. + return + self.wait() diff --git a/tests/load/agentcore_load/scenarios/readonly.py b/tests/load/agentcore_load/scenarios/readonly.py new file mode 100644 index 000000000..c79c50767 --- /dev/null +++ b/tests/load/agentcore_load/scenarios/readonly.py @@ -0,0 +1,87 @@ +"""Cheap scenario: authenticated reads, no inference. + +Value of running this separately: it loads the same ALB, the same Fargate +service, the same session-refresh middleware and the same DynamoDB tables as +the chat path, but spends nothing on Bedrock. If latency degrades here too, +the problem is the BFF layer; if it degrades only on ``/chat/stream``, the +problem is downstream. It is also the right scenario for finding out how many +concurrent signed-in users app-api can hold before anything is asked of a +model. + +Every endpoint below is a plain GET on a non-admin router. Paths are the ones +FastAPI actually registers, which is not always the prefix: a router declaring +``@router.get("/")`` serves ``/prefix/``, and a prefix with no root route (as +``/costs`` has none) 404s. Check the router, not the prefix, before adding a +task here. +""" + +from __future__ import annotations + +from locust import between, task + +from ..users import AuthenticatedUser + + +class BrowsingUser(AuthenticatedUser): + """Polls the endpoints the SPA reads while a user clicks around.""" + + wait_time = between(1, 5) + + @task(5) + def list_sessions(self) -> None: + self._get("/sessions") + + @task(3) + def list_models(self) -> None: + self._get("/models") + + @task(2) + def list_tools(self) -> None: + # Declared as @router.get("/") on a prefix="/tools" router, so the real + # path is /tools/ and FastAPI 307s /tools -> /tools/. Requesting the + # canonical path avoids charging this task two round trips per + # iteration, which would show up as inflated request counts rather + # than as an error. + self._get("/tools/") + + @task(2) + def read_settings(self) -> None: + self._get("/users/me/settings") + + @task(1) + def read_session(self) -> None: + # The SPA's bootstrap call. Also the cheapest way to confirm the + # session is still alive under load. + self._get("/auth/session") + + @task(1) + def read_costs(self) -> None: + # /costs has no root route — the costs router defines only /summary and + # /detailed-report. This is the fast path the SPA reads. + self._get("/costs/summary") + + def _get(self, path: str) -> None: + with self.client.get(path, catch_response=True, name=f"GET {path}") as response: + if response.status_code == 200: + response.success() + return + if response.status_code == 403: + # RBAC varies by deployment and by the role the load-test users + # were given. Naming it keeps a permissions gap from being read + # as a performance problem. + response.failure( + f"403 — the load-test user lacks the scope for {path}; " + "grant it or drop this task" + ) + return + if response.status_code == 404: + # A wrong path fails every single iteration at near-zero cost, + # so it contributes no load while inflating the aggregate error + # rate — which is one of the signals this scenario exists to + # measure. Say so plainly instead of reporting it as a status. + response.failure( + f"404 — {path} does not exist on this deployment; " + "this is a bug in the scenario, not a backend failure" + ) + return + response.failure(f"unexpected status {response.status_code}") diff --git a/tests/load/agentcore_load/sse.py b/tests/load/agentcore_load/sse.py new file mode 100644 index 000000000..56a0b08f7 --- /dev/null +++ b/tests/load/agentcore_load/sse.py @@ -0,0 +1,96 @@ +"""Minimal SSE reader for the chat stream. + +Only what the chat path actually emits, which is named events produced by +``agents/main_agent/streaming/stream_coordinator.py``: + + event: message_start data: {"role": "assistant"} + event: content_block_start data: {"contentBlockIndex": 0, "type": "text"} + event: content_block_delta data: {"contentBlockIndex": 0, "type": "text", ...} + event: content_block_stop data: {"contentBlockIndex": 0} + event: message_stop data: {"stopReason": "end_turn"} + event: done data: {} + +Side channels (``metadata``, ``compaction``, ``artifact``, ``ui_resource``, +``oauth_required``) ride the same stream, and some arrive *after* +``message_stop`` by design. ``done`` is therefore the only reliable +end-of-stream marker — stopping at ``message_stop`` would truncate a stream +the server is still writing to and show up as a client-side error. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from dataclasses import dataclass + +# The first delta is the user-visible "the assistant is answering" moment, so +# it is what time-to-first-token should measure. +FIRST_TOKEN_EVENT = "content_block_delta" +TURN_END_EVENT = "done" +MESSAGE_STOP_EVENT = "message_stop" + + +@dataclass(frozen=True) +class SseEvent: + name: str + data: dict + + @property + def is_first_token_candidate(self) -> bool: + return self.name == FIRST_TOKEN_EVENT + + @property + def text(self) -> str: + value = self.data.get("text") + return value if isinstance(value, str) else "" + + +def iter_sse_events(lines: Iterator[str]) -> Iterator[SseEvent]: + """Turn a stream of decoded lines into events. + + Accumulates ``event:``/``data:`` fields and dispatches on the blank line + that terminates each SSE block. Unparseable ``data:`` payloads are yielded + with an empty dict rather than raising — a malformed side-channel event + should not abort a turn that is otherwise streaming fine. + """ + event_name: str | None = None + data_parts: list[str] = [] + + for raw_line in lines: + line = raw_line.rstrip("\r") + + if line == "": + if event_name is not None: + yield SseEvent(name=event_name, data=_parse_data(data_parts)) + event_name = None + data_parts = [] + continue + + if line.startswith(":"): + # Comment / keepalive. app-api sends these to stop intermediaries + # from cutting an idle stream. + continue + + field, _, value = line.partition(":") + value = value[1:] if value.startswith(" ") else value + + if field == "event": + event_name = value + elif field == "data": + data_parts.append(value) + + # A stream that ends without its terminating blank line still has a + # complete event buffered; emitting it keeps a hard client disconnect from + # silently losing the final `done`. + if event_name is not None: + yield SseEvent(name=event_name, data=_parse_data(data_parts)) + + +def _parse_data(parts: list[str]) -> dict: + if not parts: + return {} + try: + parsed = json.loads("\n".join(parts)) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} diff --git a/tests/load/agentcore_load/users.py b/tests/load/agentcore_load/users.py new file mode 100644 index 000000000..26449c03f --- /dev/null +++ b/tests/load/agentcore_load/users.py @@ -0,0 +1,460 @@ +"""Base user classes: login once, then behave like a browser tab. + +Metric design (this is the part worth understanding before reading a report): + +* ``POST /chat/stream`` is reported by Locust itself, and because the request + is made with ``stream=True`` its response time is **time to response + headers** — not the duration of the turn. Locust sets ``response_length`` to + 0 for streamed requests, so the byte counts on that row are meaningless. +* ``SSE chat: time to first token`` and ``SSE chat: full turn`` are fired + explicitly below. These are the numbers that describe user experience. + +Splitting them matters here. Per the observability steering doc, ALB +``TargetResponseTime`` does not complete until the stream closes, so a healthy +long turn looks like a slow request from the infrastructure's point of view. +Measuring time-to-first-token client-side is the only way to see whether the +platform got *responsive* quickly, independent of how long the answer was. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from collections import deque +from collections.abc import Sequence + +from locust import HttpUser, between, events +from locust.exception import StopUser + +from .auth import LoginError, establish_bff_session +from .config import ConfigError, Credential, LoadConfig, load_config, validate_host +from .sse import TURN_END_EVENT, SseEvent, iter_sse_events + +logger = logging.getLogger(__name__) + +TTFT_METRIC = "SSE chat: time to first token" +TURN_METRIC = "SSE chat: full turn" + + +class CredentialExhausted(RuntimeError): + """Raised when more simulated users are started than there are accounts.""" + + +class CredentialPool: + """Hands each simulated user its own Cognito identity. + + Why unique assignment is the default and round-robin is not: + + Every simulated user sharing an identity also shares that identity's + ``user_id``. In this platform that means one DynamoDB partition for the + session and cost writes, one quota counter, and one memory namespace. Ten + users on one account therefore generate contention no real population of + ten users would, and the resulting latency partly measures the test's own + key collisions. That is worse than a slow test — it is a plausible-looking + wrong answer, which is the failure mode a load test exists to avoid. + + So a pool smaller than ``--users`` is a configuration error, reported + before any load starts. ``AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE`` restores + round-robin for cases where sharing is genuinely fine (a smoke run, or + measuring the login path), and says so loudly. + + Greenlet safety: Locust runs users as gevent greenlets, which switch only + on I/O. ``deque.popleft`` completes without an intervening switch, so no + lock is needed. + """ + + def __init__(self, credentials: Sequence[Credential], allow_reuse: bool = False) -> None: + if not credentials: + raise ConfigError("The credential pool is empty.") + self._all: list[Credential] = list(credentials) + self._available: deque[Credential] = deque(credentials) + self._allow_reuse = allow_reuse + self._reuse_cursor = 0 + + @property + def size(self) -> int: + return len(self._all) + + @property + def available(self) -> int: + return len(self._available) + + def acquire(self) -> tuple[Credential, bool]: + """Take a credential. Returns ``(credential, held_exclusively)``.""" + try: + credential = self._available.popleft() + except IndexError: + if not self._allow_reuse: + raise CredentialExhausted( + f"Credential pool exhausted: only {self.size} account(s) for " + f"more simulated users than that. Provision more with " + f"'scripts/load-test/provision.sh --users N', or set " + f"AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE=1 to accept shared " + f"identities (which concentrates writes on one DynamoDB " + f"partition and skews the result)." + ) from None + credential = self._all[self._reuse_cursor % self.size] + self._reuse_cursor += 1 + return credential, False + + return credential, True + + def release(self, credential: Credential, held_exclusively: bool) -> None: + """Return a credential so a long run with user churn can reuse it.""" + if held_exclusively: + self._available.append(credential) + + +# One pool per process. Under ``--processes`` each worker builds its own, so the +# stride partitioning in _build_pool is what stops two workers dealing the same +# account. +_pool: CredentialPool | None = None + + +def _worker_partition(environment) -> tuple[int, int]: + """Return this process's ``(index, total)`` slice of the pool. + + Locust workers are separate OS processes with separate module state, so + without partitioning every worker would deal from the top of the same deck + and uniqueness would hold only within a process. ``(0, 1)`` means "not + distributed, use the whole pool". + """ + if environment is None: + return 0, 1 + index = getattr(getattr(environment, "runner", None), "worker_index", None) + if index is None: + return 0, 1 + + options = getattr(environment, "parsed_options", None) + total = getattr(options, "expect_workers", None) or getattr(options, "processes", None) + if not total or int(total) < 1: + # Known worker, unknown fleet size: keep the whole pool and warn rather + # than silently slice it to the wrong width. + logger.warning( + "Running as worker %s but the worker count is unknown; credential " + "uniqueness is only guaranteed within this process.", + index, + ) + return 0, 1 + return int(index), int(total) + + +def _build_pool(config: LoadConfig, environment) -> CredentialPool: + index, total = _worker_partition(environment) + credentials = config.credentials[index::total] if total > 1 else config.credentials + if not credentials: + raise ConfigError( + f"Worker {index} of {total} got no credentials: a pool of " + f"{len(config.credentials)} does not spread across {total} workers. " + f"Provision at least one account per worker." + ) + return CredentialPool(credentials, allow_reuse=config.allow_credential_reuse) + + +def get_pool(config: LoadConfig, environment=None) -> CredentialPool: + global _pool + if _pool is None: + _pool = _build_pool(config, environment) + return _pool + + +def reset_pool() -> None: + """Drop the process-wide pool. For tests, and between runs in one process.""" + global _pool + _pool = None + + +@events.test_start.add_listener +def _check_pool_covers_user_count(environment, **_kwargs) -> None: + """Fail before spending money, not after. + + Without this, Locust starts, exhausts the pool partway through the ramp and + stops users one at a time — which reads as a flaky backend. The only place + a single clear message is possible is before the first login. + """ + try: + config = load_config() + except ConfigError as exc: + logger.error("Load test misconfigured: %s", exc) + if environment.runner: + environment.runner.quit() + return + + reset_pool() + pool = get_pool(config, environment) + + requested = getattr(getattr(environment, "parsed_options", None), "num_users", None) + if not requested: + # A LoadTestShape drives the count instead, so there is nothing to + # compare yet. Exhaustion is still caught in acquire(). + return + + if requested > pool.size and not config.allow_credential_reuse: + logger.error( + "Refusing to start: %s simulated users requested but only %s " + "credential(s) available%s. Each user needs its own Cognito identity " + "or the run measures DynamoDB partition contention that real users " + "would not create. Provision more with " + "'scripts/load-test/provision.sh --users %s', or set " + "AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE=1 to override.", + requested, + pool.size, + " for this worker" if pool.size != len(config.credentials) else "", + requested, + ) + if environment.runner: + environment.runner.quit() + + +class TurnResult: + """What one chat turn produced.""" + + __slots__ = ("chars", "first_token_ms", "total_ms", "stop_reason") + + def __init__( + self, + chars: int, + first_token_ms: float | None, + total_ms: float, + stop_reason: str | None, + ) -> None: + self.chars = chars + self.first_token_ms = first_token_ms + self.total_ms = total_ms + self.stop_reason = stop_reason + + +class AuthenticatedUser(HttpUser): + """Logs in through the Hosted UI on start, then holds the session. + + Subclass and add tasks. One login per simulated user for the whole run, + which is what a real browser tab does — re-authenticating per request would + both distort the load and hammer Cognito. + """ + + abstract = True + wait_time = between(5, 15) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.load_config: LoadConfig | None = None + self.csrf_token: str | None = None + self.credential: Credential | None = None + self._credential_exclusive = False + + def on_start(self) -> None: + try: + self.host = validate_host(self.host) + self.load_config = load_config() + except ConfigError as exc: + # Configuration problems affect every user identically, so there is + # no point letting hundreds of them fail one at a time. + logger.error("Load test misconfigured: %s", exc) + raise StopUser() from exc + + try: + self.credential, self._credential_exclusive = get_pool( + self.load_config, self.environment + ).acquire() + except (CredentialExhausted, ConfigError) as exc: + # Only reachable when a LoadTestShape drives the user count past the + # pool, since test_start catches the --users case up front. + logger.error("%s", exc) + raise StopUser() from exc + try: + self.csrf_token = establish_bff_session(self.client, self.load_config, self.credential) + except LoginError as exc: + logger.error("Login failed for %s: %s", self.credential.username, exc) + raise StopUser() from exc + + def on_stop(self) -> None: + """Drop the server-side session so a run does not leave rows behind.""" + try: + if not self.csrf_token: + return + self.client.post( + "/auth/logout", + headers={"X-CSRF-Token": self.csrf_token}, + name="POST /auth/logout", + ) + finally: + # Return the identity even if logout failed, so a run that churns + # users does not leak the pool away and then report exhaustion. + if self.credential is not None and self.load_config is not None: + get_pool(self.load_config, self.environment).release( + self.credential, self._credential_exclusive + ) + self.credential = None + + @property + def csrf_headers(self) -> dict[str, str]: + """CSRF header for unsafe requests. + + ``CSRFMiddleware`` enforces the double-submit check on every unsafe + method once a BFF session is present, and ``/chat/stream`` is not + exempt — there is a backend test asserting exactly that. + """ + return {"X-CSRF-Token": self.csrf_token} if self.csrf_token else {} + + +class ChatUser(AuthenticatedUser): + """Adds the SSE-instrumented chat turn.""" + + abstract = True + + def new_conversation_id(self) -> str: + """Mint a conversation id. + + Client-generated, matching the SPA: the backend creates the session row + during the first turn rather than requiring a prior POST. + """ + return str(uuid.uuid4()) + + def build_payload(self, session_id: str, message: str) -> dict: + config = self.load_config + assert config is not None # on_start guarantees this or stops the user + + # Mirrors buildChatRequestObject in the SPA's chat-request.service.ts. + # None for model_id/provider is the "system default" signal. + return { + "session_id": session_id, + "message": message, + "model_id": config.model_id, + "provider": config.provider, + "enabled_tools": config.enabled_tools, + } + + def chat_turn(self, session_id: str, message: str) -> TurnResult | None: + """Send one turn and consume the whole stream. + + Returns ``None`` when the turn failed; the failure is already recorded + against the relevant metric. + """ + config = self.load_config + assert config is not None + + payload = self.build_payload(session_id, message) + headers = { + "Content-Type": "application/json", + "Accept": "text/event-stream", + **self.csrf_headers, + } + + started = time.perf_counter() + first_token_at: float | None = None + chars = 0 + stop_reason: str | None = None + saw_terminator = False + + with self.client.post( + "/chat/stream", + json=payload, + headers=headers, + stream=True, + catch_response=True, + name="POST /chat/stream", + timeout=config.turn_timeout_seconds, + ) as response: + if response.status_code != 200: + detail = self._describe_error(response.status_code) + response.failure(detail) + return None + + # Headers are in. Everything past here is stream time, which the + # two custom metrics below cover. + response.success() + + try: + for event in iter_sse_events(response.iter_lines(decode_unicode=True)): + if first_token_at is None and event.is_first_token_candidate: + first_token_at = time.perf_counter() + self._fire( + TTFT_METRIC, + (first_token_at - started) * 1000.0, + ) + + chars += len(event.text) + stop_reason = self._track_stop_reason(event, stop_reason) + + if event.name == TURN_END_EVENT: + saw_terminator = True + break + except Exception as exc: # noqa: BLE001 - reported, not swallowed + self._fire( + TURN_METRIC, + (time.perf_counter() - started) * 1000.0, + exception=exc, + ) + return None + + total_ms = (time.perf_counter() - started) * 1000.0 + + if not saw_terminator: + self._fire( + TURN_METRIC, + total_ms, + exception=RuntimeError( + f"stream ended without a '{TURN_END_EVENT}' event " + f"(last stopReason={stop_reason!r})" + ), + ) + return None + + if stop_reason == "error": + # The agent emitted an error turn: quota block, model failure, or a + # tool blowing up. HTTP was 200, so only the stream reveals it. + self._fire( + TURN_METRIC, + total_ms, + exception=RuntimeError("agent returned stopReason=error"), + ) + return None + + self._fire(TURN_METRIC, total_ms, response_length=chars) + return TurnResult( + chars=chars, + first_token_ms=None if first_token_at is None else (first_token_at - started) * 1000.0, + total_ms=total_ms, + stop_reason=stop_reason, + ) + + @staticmethod + def _track_stop_reason(event: SseEvent, current: str | None) -> str | None: + if event.name != "message_stop": + return current + reason = event.data.get("stopReason") + return str(reason) if reason else current + + def _fire( + self, + name: str, + response_time_ms: float, + response_length: int = 0, + exception: BaseException | None = None, + ) -> None: + self.environment.events.request.fire( + request_type="SSE", + name=name, + response_time=response_time_ms, + response_length=response_length, + exception=exception, + context=self.context(), + ) + + @staticmethod + def _describe_error(status_code: int) -> str: + """Attach the likely cause to the status code. + + These three are the ones a load run actually hits, and each is a + different kind of problem — worth naming so a report does not just say + "403" and leave the reader guessing. + """ + hints = { + 401: "401 — no active BFF session (cookie expired or not stored; https required)", + 403: "403 — CSRF token missing or invalid", + 429: "429 — rate limited", + 502: "502 — inference API unreachable", + 504: "504 — inference API timed out", + } + return hints.get(status_code, f"unexpected status {status_code}") diff --git a/tests/load/locustfile.py b/tests/load/locustfile.py new file mode 100644 index 000000000..b6f633bd0 --- /dev/null +++ b/tests/load/locustfile.py @@ -0,0 +1,16 @@ +"""Default entry point — the chat path. + + locust -f locustfile.py --host https://chat.example.edu/api + +Every simulated turn spends real Bedrock tokens against a real user's quota. +Read tests/load/README.md before running this against anything. + +For the free read-only scenario use `locustfile_readonly.py` instead. The two +are separate files on purpose: Locust runs every user class it finds in a +locustfile, so keeping them together would make "I just wanted the cheap test" +an expensive mistake. +""" + +from agentcore_load.scenarios.chat import ChatConversationUser + +__all__ = ["ChatConversationUser"] diff --git a/tests/load/locustfile_classroom.py b/tests/load/locustfile_classroom.py new file mode 100644 index 000000000..ab3a6d3b6 --- /dev/null +++ b/tests/load/locustfile_classroom.py @@ -0,0 +1,40 @@ +"""Classroom-burst entry point — the campus-realistic worst case. + + source tests/load/profiles/campus-representative.env + locust -f locustfile_classroom.py --host https://boisestate.ai/api --headless + +``--users`` and ``--spawn-rate`` are IGNORED: ClassroomBurstShape drives the +user count. ``--run-time`` is also unnecessary; the shape ends the run itself. + +WARNING: this is the expensive one, and it is designed to be pointed at +production. Every turn is a live Bedrock invocation on the deployment's default +model. At production's measured ~26,700 quota tokens per turn, a 300-user burst +issuing one turn each inside a minute consumes ~8,000,000 tokens/minute against +an applied quota of 6,000,000 — so throttling is an *expected outcome* of the +default settings, not a bug. That is the finding the test exists to produce. +Run scripts/load-test/watch-tpm.sh alongside it. + +Differences from the steady-state chat scenario, and why: + +* ``wait_time`` is 1-3s rather than 5-15s. A class asked to do a thing does it + immediately; think time is what a browsing population has. +* Fewer turns per conversation. A burst is a first question and maybe a + follow-up, not a working session. That comes from config rather than this + class, so set it explicitly after sourcing the profile: + + export AGENTCORE_LOAD_TURNS_PER_CONVERSATION=2 +""" + +from locust import between + +from agentcore_load.scenarios.chat import ChatConversationUser +from shapes import ClassroomBurstShape + + +class ClassroomUser(ChatConversationUser): + """A student following an instruction, not browsing.""" + + wait_time = between(1, 3) + + +__all__ = ["ClassroomUser", "ClassroomBurstShape"] diff --git a/tests/load/locustfile_readonly.py b/tests/load/locustfile_readonly.py new file mode 100644 index 000000000..56df255d7 --- /dev/null +++ b/tests/load/locustfile_readonly.py @@ -0,0 +1,12 @@ +"""Read-only entry point — authenticated GETs, no inference, no Bedrock spend. + + locust -f locustfile_readonly.py --host https://chat.example.edu/api + +Use this to load-test the BFF layer (ALB, Fargate, session middleware, +DynamoDB) in isolation, and to establish how many concurrent signed-in users +app-api sustains before any model is involved. +""" + +from agentcore_load.scenarios.readonly import BrowsingUser + +__all__ = ["BrowsingUser"] diff --git a/tests/load/profiles/campus-representative.env b/tests/load/profiles/campus-representative.env new file mode 100644 index 000000000..1eb521348 --- /dev/null +++ b/tests/load/profiles/campus-representative.env @@ -0,0 +1,65 @@ +# Production-representative load profile. source this, do not execute it. +# +# source tests/load/profiles/campus-representative.env +# +# WHY THIS EXISTS +# +# The default profile is deliberately cheap: no tools, short prompts, 3 turns. +# Measured against the real production deployment, that understates token +# consumption by roughly 14x per turn: +# +# production (measured) default profile +# input tokens 384 1,781 +# cache-read tokens 24,926 0 +# output tokens 1,372 139 +# TOTAL per turn ~26,700 ~1,920 +# +# Cache-read tokens count against the Bedrock TPM quota (verified against +# EstimatedTPMQuotaUsage: 8 invocations consumed 232,119 quota tokens, which is +# 16x what input+output alone would be). So a load test run on the default +# profile can pass comfortably while the same user count in production would +# throttle. It is measuring the wrong workload. +# +# Two levers close the gap: +# +# TOOLS. The ~25k cached tokens are mostly the system prompt plus the JSON +# schemas of the user's enabled tools. Enabling a realistic tool set +# reproduces that prefix. Note the tools are enabled to inflate the *prompt*, +# not to be *called* — the campus-representative prompts are general-knowledge +# questions answerable without tool use, deliberately, because invoking +# Canvas / Salesforce / PeopleSoft / Brave Search at 300-1300x concurrency +# would put real load on institutional and third-party systems that have +# nothing to do with what this test measures. +# +# CONVERSATION DEPTH. Turns share a session_id, so history accumulates and +# later turns carry a larger cached prefix. Six turns approximates a real +# working conversation; three barely warms the cache. +# +# Adjust ENABLED_TOOLS to match what your users actually have. Discover the +# real list for a given user with an authenticated `GET /tools/`. + +# A plausible student/faculty selection from the production catalog. Twelve of +# twenty-eight, which is what drives prompt size. +export AGENTCORE_LOAD_ENABLED_TOOLS="calculator,create_visualization,create_artifact,fetch_url_content,class_search,gateway_arxiv,gateway_semantic_scholar,gateway_search_boise_state,gateway_brave_search,list_spreadsheets,analyze_spreadsheet,create_word_document" + +# Long-form answers, ~1,372 output tokens like production. +export AGENTCORE_LOAD_PROMPTS_FILE="tests/load/prompts/campus-representative.txt" + +# Deep enough that history and the prompt cache both matter. +export AGENTCORE_LOAD_TURNS_PER_CONVERSATION=6 + +# Leave the model unset so the run uses the deployment's system default, which +# in production is Claude Sonnet 5 (global.anthropic.claude-sonnet-5) — not the +# Haiku 4.5 that dev defaults to. Setting it explicitly is how you end up +# load-testing a model nobody uses. +unset AGENTCORE_LOAD_MODEL_ID +unset AGENTCORE_LOAD_PROVIDER + +echo "Loaded campus-representative profile:" +echo " tools : 12 enabled (inflates the cached prefix; prompts avoid calling them)" +echo " prompts : ${AGENTCORE_LOAD_PROMPTS_FILE}" +echo " turns : ${AGENTCORE_LOAD_TURNS_PER_CONVERSATION} per conversation" +echo " model : deployment system default" +echo +echo "Expect ~26,700 quota tokens per turn. At the current 6,000,000 TPM quota" +echo "that is a hard ceiling of ~225 turns/minute for the whole platform." diff --git a/tests/load/prompts/campus-representative.txt b/tests/load/prompts/campus-representative.txt new file mode 100644 index 000000000..76d4ebf37 --- /dev/null +++ b/tests/load/prompts/campus-representative.txt @@ -0,0 +1,27 @@ +# Prompts for the production-representative profile. +# +# Chosen to satisfy two constraints at once: +# +# 1. Elicit a LONG answer. Production averages ~1,372 output tokens per turn; +# the built-in prompts average ~139, which understates TPM by 10x on the +# output side alone. Each prompt below explicitly asks for detail. +# 2. Be answerable WITHOUT calling a tool. The representative profile enables a +# realistic tool set because tool schemas are what inflate the cached prompt +# prefix, but actually invoking them at 300-1300x concurrency would put real +# load on Canvas, Salesforce, PeopleSoft, Brave Search and arXiv. These are +# general-knowledge questions a model answers from its own weights. +# +# One prompt per line. Blank lines and lines starting with # are ignored. + +Explain the difference between processes and threads in an operating system. Cover memory isolation, context-switching cost, and when you would choose one over the other. Give concrete examples. +Walk me through how HTTPS establishes a secure connection, from DNS resolution through certificate validation to symmetric key exchange. Explain what each step protects against. +Describe the main causes of database index fragmentation, how it affects query performance, and the tradeoffs of different remedies. Be thorough. +Explain the CAP theorem and why it constrains distributed database design. Give a worked example of a system choosing consistency and one choosing availability. +Compare supervised, unsupervised, and reinforcement learning. For each, describe the training signal, a typical algorithm, and a realistic application. Explain the failure modes. +Explain how garbage collection works in a managed runtime. Cover generational collection, mark-and-sweep, and why pause times matter for latency-sensitive services. +Describe the layers of the TCP/IP model and what each contributes. Explain how a packet is encapsulated on the way out and decapsulated on the way in. +Explain what a race condition is, why it is hard to reproduce, and four distinct strategies for preventing one. Include the tradeoffs of each strategy. +Explain the tradeoffs between normalized and denormalized data models. Describe when each is appropriate and what problems each creates at scale. +Describe how public-key cryptography enables digital signatures. Explain the role of hashing, why the private key never leaves the signer, and how trust is established. +Explain what causes cache invalidation problems in a distributed system, and compare write-through, write-back, and write-around strategies in detail. +Describe the phases of a compiler from source text to machine code. Explain what each phase produces and what class of error each can detect. diff --git a/tests/load/pyproject.toml b/tests/load/pyproject.toml new file mode 100644 index 000000000..469f2fc08 --- /dev/null +++ b/tests/load/pyproject.toml @@ -0,0 +1,35 @@ +[project] +# Deliberately a separate project from `backend/`. Locust is a load-testing +# tool, not an application dependency — adding it to backend/pyproject.toml +# (even as an optional extra) would land it in backend/uv.lock, which CI +# verifies and the release sync script regenerates, and would drag it into +# the app-api / inference-api image dependency resolution. +name = "agentcore-load" +version = "0.1.0" +description = "Locust load tests for the AgentCore platform BFF chat path" +requires-python = ">=3.13" + +dependencies = [ + "locust==2.46.4", +] + +[dependency-groups] +dev = [ + "pytest==8.4.2", + "ruff==0.14.5", +] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +# `agentcore_load` is imported from the project root, which is how Locust +# resolves it too (it puts the locustfile's directory on sys.path). Mirroring +# that here keeps the tests importing exactly what a real run imports, with no +# install step. +pythonpath = ["."] +testpaths = ["tests"] diff --git a/tests/load/shapes.py b/tests/load/shapes.py new file mode 100644 index 000000000..bf1a65175 --- /dev/null +++ b/tests/load/shapes.py @@ -0,0 +1,108 @@ +"""Load shapes. A shape drives the user count over time instead of ``--users``. + +Why a shape rather than a longer ramp: + +``--users 300 --spawn-rate 5`` reaches 300 users in a minute and holds there. +That is a *campus* — a large population arriving gradually and clicking +independently. It is not a *classroom*, which is the shape that actually +threatens this platform: an instructor says "ask the assistant about X" and 250 +to 300 students submit inside about thirty seconds. The infrastructure sees a +near-vertical edge, and the things that break on an edge (ALB connection surge, +Fargate scaling from a cold 2 tasks with 60s cooldowns, Bedrock TPM measured +per minute) do not break on a ramp of the same height. + +Locust ignores ``--users`` and ``--spawn-rate`` when a shape is present. +""" + +from __future__ import annotations + +import os + +from locust import LoadTestShape + + +def _int_env(name: str, default: int) -> int: + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError as exc: + raise ValueError(f"{name} must be an integer, got {raw!r}") from exc + if value < 0: + raise ValueError(f"{name} must be >= 0, got {value}") + return value + + +class ClassroomBurstShape(LoadTestShape): + """One lecture hall, twice. + + ====================== ========================================== + phase what it represents + ====================== ========================================== + baseline ambient campus traffic before class + spike the instructor's instruction lands + hold everyone working through their answer + drain back to ambient + second spike the next class, on warmed infrastructure + ====================== ========================================== + + The second spike is the point of the whole shape. The first one hits cold: + two Fargate tasks, an empty prompt cache, no scaled-out capacity. If the + platform only survives the second, then it survives a class *following* + another class and not the 9am one — which is a materially different answer + and one a single-spike test cannot distinguish. + + Tunable with ``AGENTCORE_LOAD_BURST_*`` env vars; defaults model a + 300-student section. + """ + + def __init__(self) -> None: + super().__init__() + self.baseline_users = _int_env("AGENTCORE_LOAD_BURST_BASELINE_USERS", 20) + self.burst_users = _int_env("AGENTCORE_LOAD_BURST_USERS", 300) + self.burst_seconds = _int_env("AGENTCORE_LOAD_BURST_SECONDS", 30) + self.hold_seconds = _int_env("AGENTCORE_LOAD_BURST_HOLD_SECONDS", 240) + self.drain_seconds = _int_env("AGENTCORE_LOAD_BURST_DRAIN_SECONDS", 120) + self.bursts = _int_env("AGENTCORE_LOAD_BURST_COUNT", 2) + self.warmup_seconds = _int_env("AGENTCORE_LOAD_BURST_WARMUP_SECONDS", 60) + + if self.burst_users < self.baseline_users: + raise ValueError( + f"AGENTCORE_LOAD_BURST_USERS ({self.burst_users}) must be >= " + f"AGENTCORE_LOAD_BURST_BASELINE_USERS ({self.baseline_users})." + ) + if self.burst_seconds < 1: + raise ValueError("AGENTCORE_LOAD_BURST_SECONDS must be >= 1.") + + # Spawn fast enough to clear the gap inside the burst window. This is + # the number that makes it a burst rather than a ramp; at the defaults + # it is (300-20)/30 = ~10 users/second, each doing a full OAuth login. + self._spawn_rate = max( + 1, round((self.burst_users - self.baseline_users) / self.burst_seconds) + ) + self._cycle_seconds = self.burst_seconds + self.hold_seconds + self.drain_seconds + + @property + def total_seconds(self) -> int: + """Full run length, so an operator can sanity-check cost before starting.""" + return self.warmup_seconds + self.bursts * self._cycle_seconds + + def tick(self) -> tuple[int, float] | None: + run_time = self.get_run_time() + + if run_time >= self.total_seconds: + return None + + if run_time < self.warmup_seconds: + # Baseline first: a cold ALB and an unwarmed prompt cache would + # otherwise be attributed to the spike. + return self.baseline_users, max(1, self.baseline_users // 10) + + offset = (run_time - self.warmup_seconds) % self._cycle_seconds + + if offset < self.burst_seconds: + return self.burst_users, self._spawn_rate + if offset < self.burst_seconds + self.hold_seconds: + return self.burst_users, self._spawn_rate + return self.baseline_users, self._spawn_rate diff --git a/tests/load/tests/test_auth_form.py b/tests/load/tests/test_auth_form.py new file mode 100644 index 000000000..c34638848 --- /dev/null +++ b/tests/load/tests/test_auth_form.py @@ -0,0 +1,97 @@ +"""Tests for the Cognito Hosted UI form parser. + +This is the most fragile part of the suite — it depends on Cognito's markup — +so it gets the most coverage. The failure mode that matters is a *silent* one: +picking the wrong form or dropping a hidden field would produce a login that +fails for reasons that look like the backend's fault. +""" + +from __future__ import annotations + +import pytest + +from agentcore_load.auth import LoginError, _find_login_form, _pick_username_field + +# Shape of the classic Hosted UI page: a search/nav form first, then the real +# sign-in form. Picking the first form on the page would pick the wrong one. +HOSTED_UI_HTML = """ + + + +
+
+ + + + +
+ +""" + + +def test_picks_the_form_containing_a_password_field() -> None: + form = _find_login_form(HOSTED_UI_HTML) + assert form.method == "post" + assert form.action is not None and form.action.startswith("/login?client_id=abc") + assert form.password_field == "password" + + +def test_hidden_csrf_field_is_carried_forward() -> None: + # Cognito rejects the POST without its own _csrf value. + form = _find_login_form(HOSTED_UI_HTML) + assert form.fields["_csrf"] == "csrf-abc123" + + +def test_html_entities_in_action_are_decoded() -> None: + # The action contains & — if that is not decoded the query string is + # malformed and Cognito answers 400. + form = _find_login_form(HOSTED_UI_HTML) + assert "&" not in (form.action or "") + assert "&redirect_uri=" in (form.action or "") + + +def test_password_value_is_never_captured() -> None: + html = """ +
+ +
+ """ + assert _find_login_form(html).fields["password"] == "" + + +def test_missing_password_form_raises_with_diagnostics() -> None: + html = '
' + with pytest.raises(LoginError) as excinfo: + _find_login_form(html) + # The message has to name what it saw, or a markup change is unfixable + # from a log line alone. + assert "managed login" in str(excinfo.value) + assert "Parsed 1 form" in str(excinfo.value) + + +def test_username_field_resolved_by_name() -> None: + assert _pick_username_field(_find_login_form(HOSTED_UI_HTML)) == "username" + + +def test_username_field_falls_back_to_sole_text_input() -> None: + html = """ +
+ + + +
+ """ + assert _pick_username_field(_find_login_form(html)) == "weirdlyNamedField" + + +def test_ambiguous_username_field_raises() -> None: + html = """ +
+ + + +
+ """ + with pytest.raises(LoginError, match="username field"): + _pick_username_field(_find_login_form(html)) diff --git a/tests/load/tests/test_config.py b/tests/load/tests/test_config.py new file mode 100644 index 000000000..067455ded --- /dev/null +++ b/tests/load/tests/test_config.py @@ -0,0 +1,150 @@ +"""Tests for configuration loading and the fail-fast guards. + +Every check here exists to convert a misconfiguration into an immediate, +readable error instead of a run that generates load and fails every request — +which reads as a broken platform rather than a broken test. +""" + +from __future__ import annotations + +import json + +import pytest + +from agentcore_load.config import ConfigError, load_config, validate_host + +COGNITO = "https://example.auth.us-west-2.amazoncognito.com" + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "AGENTCORE_LOAD_COGNITO_DOMAIN", + "AGENTCORE_LOAD_USERS_FILE", + "AGENTCORE_LOAD_USERNAME", + "AGENTCORE_LOAD_PASSWORD", + "AGENTCORE_LOAD_MODEL_ID", + "AGENTCORE_LOAD_PROVIDER", + "AGENTCORE_LOAD_ENABLED_TOOLS", + "AGENTCORE_LOAD_TURNS_PER_CONVERSATION", + "AGENTCORE_LOAD_PROMPTS_FILE", + ): + monkeypatch.delenv(name, raising=False) + + +def test_http_host_is_rejected() -> None: + # The __Host- cookie prefix requires Secure, so over http the session + # cookie is dropped and every chat request 401s. + with pytest.raises(ConfigError, match="must be https"): + validate_host("http://localhost:8000") + + +def test_missing_host_is_rejected() -> None: + with pytest.raises(ConfigError, match="No --host"): + validate_host(None) + + +def test_trailing_slash_stripped_from_host() -> None: + assert validate_host("https://chat.example.edu/api/") == "https://chat.example.edu/api" + + +def test_cognito_domain_must_be_https(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", "http://example.com") + monkeypatch.setenv("AGENTCORE_LOAD_USERNAME", "u") + monkeypatch.setenv("AGENTCORE_LOAD_PASSWORD", "p") + with pytest.raises(ConfigError, match="must be an https"): + load_config() + + +def test_missing_cognito_domain_names_the_variable() -> None: + with pytest.raises(ConfigError, match="AGENTCORE_LOAD_COGNITO_DOMAIN"): + load_config() + + +def test_missing_credentials_explains_both_options(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + with pytest.raises(ConfigError, match="AGENTCORE_LOAD_USERS_FILE"): + load_config() + + +def test_single_credential_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + monkeypatch.setenv("AGENTCORE_LOAD_USERNAME", "loadtest01") + monkeypatch.setenv("AGENTCORE_LOAD_PASSWORD", "secret") + + config = load_config() + assert len(config.credentials) == 1 + assert config.credentials[0].username == "loadtest01" + # Defaults: system default model, no tools. + assert config.model_id is None + assert config.enabled_tools == [] + + +def test_manifest_file_loads_a_pool(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + manifest = tmp_path / "users.json" + manifest.write_text( + json.dumps( + [ + {"username": "load01", "password": "a"}, + {"username": "load02", "password": "b"}, + ] + ) + ) + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + monkeypatch.setenv("AGENTCORE_LOAD_USERS_FILE", str(manifest)) + + config = load_config() + assert [c.username for c in config.credentials] == ["load01", "load02"] + + +def test_manifest_entry_missing_password_is_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + manifest = tmp_path / "users.json" + manifest.write_text(json.dumps([{"username": "load01"}])) + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + monkeypatch.setenv("AGENTCORE_LOAD_USERS_FILE", str(manifest)) + + with pytest.raises(ConfigError, match="needs both"): + load_config() + + +def test_empty_manifest_is_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + manifest = tmp_path / "users.json" + manifest.write_text("[]") + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + monkeypatch.setenv("AGENTCORE_LOAD_USERS_FILE", str(manifest)) + + with pytest.raises(ConfigError, match="non-empty JSON array"): + load_config() + + +def test_credential_repr_hides_the_password(monkeypatch: pytest.MonkeyPatch) -> None: + # Locust prints exception context into its stats tables; a credential must + # never be readable from a traceback. + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + monkeypatch.setenv("AGENTCORE_LOAD_USERNAME", "loadtest01") + monkeypatch.setenv("AGENTCORE_LOAD_PASSWORD", "super-secret-value") + + rendered = repr(load_config().credentials[0]) + assert "super-secret-value" not in rendered + assert "***" in rendered + + +def test_turns_must_be_positive(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + monkeypatch.setenv("AGENTCORE_LOAD_USERNAME", "u") + monkeypatch.setenv("AGENTCORE_LOAD_PASSWORD", "p") + monkeypatch.setenv("AGENTCORE_LOAD_TURNS_PER_CONVERSATION", "0") + + with pytest.raises(ConfigError, match=">= 1"): + load_config() + + +def test_enabled_tools_parsed_as_csv(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_COGNITO_DOMAIN", COGNITO) + monkeypatch.setenv("AGENTCORE_LOAD_USERNAME", "u") + monkeypatch.setenv("AGENTCORE_LOAD_PASSWORD", "p") + monkeypatch.setenv("AGENTCORE_LOAD_ENABLED_TOOLS", "calculator, web_search ,") + + assert load_config().enabled_tools == ["calculator", "web_search"] diff --git a/tests/load/tests/test_credential_pool.py b/tests/load/tests/test_credential_pool.py new file mode 100644 index 000000000..34ae98fc7 --- /dev/null +++ b/tests/load/tests/test_credential_pool.py @@ -0,0 +1,141 @@ +"""Tests for credential assignment. + +The behaviour under test is a refusal, not a feature. A pool smaller than the +requested user count used to be handled silently by round-robin, which produced +runs where many simulated users shared one ``user_id`` — and therefore one +DynamoDB partition, one quota counter and one memory namespace. The measured +latency then partly described the test's own key collisions. These tests pin the +refusal so that cannot come back quietly. +""" + +from __future__ import annotations + +import pytest + +from agentcore_load.config import ConfigError, Credential +from agentcore_load.users import ( + CredentialExhausted, + CredentialPool, + _worker_partition, + reset_pool, +) + + +def _creds(n: int) -> list[Credential]: + return [Credential(username=f"loadtest-{i:02d}", password="pw") for i in range(n)] + + +@pytest.fixture(autouse=True) +def _reset() -> None: + reset_pool() + yield + reset_pool() + + +class TestUniqueAssignment: + def test_each_user_gets_a_distinct_credential(self) -> None: + pool = CredentialPool(_creds(5)) + issued = [pool.acquire() for _ in range(5)] + + usernames = [c.username for c, _ in issued] + assert len(set(usernames)) == 5 + assert all(exclusive for _, exclusive in issued) + assert pool.available == 0 + + def test_exhaustion_raises_with_an_actionable_message(self) -> None: + pool = CredentialPool(_creds(2)) + pool.acquire() + pool.acquire() + + with pytest.raises(CredentialExhausted) as exc: + pool.acquire() + + message = str(exc.value) + # The fix is to provision more accounts, so the error has to say so. + assert "provision.sh" in message + assert "AGENTCORE_LOAD_ALLOW_CREDENTIAL_REUSE" in message + + def test_released_credential_is_reissued(self) -> None: + pool = CredentialPool(_creds(1)) + credential, exclusive = pool.acquire() + pool.release(credential, exclusive) + + assert pool.available == 1 + again, _ = pool.acquire() + assert again.username == credential.username + + def test_release_of_a_shared_credential_does_not_grow_the_pool(self) -> None: + # Guards against a leak where reuse-mode releases inflate availability + # and a later acquire hands out a credential that is already in use. + pool = CredentialPool(_creds(1), allow_reuse=True) + first, exclusive = pool.acquire() + assert exclusive is True + shared, exclusive = pool.acquire() + assert exclusive is False + + pool.release(shared, exclusive) + assert pool.available == 0 + + def test_empty_pool_is_rejected(self) -> None: + with pytest.raises(ConfigError): + CredentialPool([]) + + +class TestOptInReuse: + def test_round_robin_cycles_once_opted_in(self) -> None: + pool = CredentialPool(_creds(3), allow_reuse=True) + for _ in range(3): + pool.acquire() + + cycled = [pool.acquire()[0].username for _ in range(4)] + assert cycled == [ + "loadtest-00", + "loadtest-01", + "loadtest-02", + "loadtest-00", + ] + + def test_reused_credentials_are_not_marked_exclusive(self) -> None: + pool = CredentialPool(_creds(1), allow_reuse=True) + pool.acquire() + _, exclusive = pool.acquire() + assert exclusive is False + + +class TestWorkerPartition: + """Locust workers are separate processes with separate module state. + + Without a stride every worker deals from the top of the same deck, so + uniqueness holds within a process and silently breaks across the fleet — + exactly the distortion the pool exists to prevent, reintroduced by + distribution. + """ + + def test_single_process_uses_the_whole_pool(self) -> None: + assert _worker_partition(None) == (0, 1) + + def test_worker_takes_a_stride(self) -> None: + environment = _FakeEnvironment(worker_index=1, expect_workers=4) + assert _worker_partition(environment) == (1, 4) + + def test_unknown_fleet_size_falls_back_to_the_whole_pool(self) -> None: + # Better to over-provision uniqueness within one process than to slice + # the pool to a guessed width and hand out duplicates. + environment = _FakeEnvironment(worker_index=2, expect_workers=None) + assert _worker_partition(environment) == (0, 1) + + def test_stride_partitions_are_disjoint(self) -> None: + credentials = _creds(10) + total = 3 + slices = [credentials[i::total] for i in range(total)] + + seen = [c.username for s in slices for c in s] + assert len(seen) == len(set(seen)) == 10 + + +class _FakeEnvironment: + def __init__(self, worker_index: int | None, expect_workers: int | None) -> None: + self.runner = type("Runner", (), {"worker_index": worker_index})() + self.parsed_options = type( + "Options", (), {"expect_workers": expect_workers, "processes": None} + )() diff --git a/tests/load/tests/test_shapes.py b/tests/load/tests/test_shapes.py new file mode 100644 index 000000000..a465273f3 --- /dev/null +++ b/tests/load/tests/test_shapes.py @@ -0,0 +1,116 @@ +"""Tests for ClassroomBurstShape. + +The shape encodes a claim about campus traffic: the risk is not 1300 users +arriving gradually but 300 arriving inside thirty seconds. These tests pin the +properties that make it a burst rather than a ramp, because a shape that +silently flattens would produce a reassuring result for the wrong scenario. +""" + +from __future__ import annotations + +import pytest + +from shapes import ClassroomBurstShape + +BURST_ENV = ( + "AGENTCORE_LOAD_BURST_BASELINE_USERS", + "AGENTCORE_LOAD_BURST_USERS", + "AGENTCORE_LOAD_BURST_SECONDS", + "AGENTCORE_LOAD_BURST_HOLD_SECONDS", + "AGENTCORE_LOAD_BURST_DRAIN_SECONDS", + "AGENTCORE_LOAD_BURST_COUNT", + "AGENTCORE_LOAD_BURST_WARMUP_SECONDS", +) + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in BURST_ENV: + monkeypatch.delenv(name, raising=False) + + +def _at(shape: ClassroomBurstShape, seconds: int): + shape.get_run_time = lambda: seconds # type: ignore[method-assign] + return shape.tick() + + +class TestTimeline: + def test_starts_at_baseline_so_the_spike_is_attributable(self) -> None: + # Without a warm-up the cold ALB and empty prompt cache would be + # charged to the burst. + shape = ClassroomBurstShape() + users, _ = _at(shape, 0) + assert users == shape.baseline_users + + def test_spike_reaches_full_burst_immediately_after_warmup(self) -> None: + shape = ClassroomBurstShape() + assert _at(shape, shape.warmup_seconds)[0] == shape.burst_users + + def test_holds_the_burst_then_drains(self) -> None: + shape = ClassroomBurstShape() + mid_hold = shape.warmup_seconds + shape.burst_seconds + 1 + assert _at(shape, mid_hold)[0] == shape.burst_users + + draining = ( + shape.warmup_seconds + shape.burst_seconds + shape.hold_seconds + 1 + ) + assert _at(shape, draining)[0] == shape.baseline_users + + def test_second_burst_fires_on_warm_infrastructure(self) -> None: + # The whole reason for two bursts: surviving a class that follows + # another class is a different claim from surviving the first class. + shape = ClassroomBurstShape() + second = shape.warmup_seconds + shape._cycle_seconds + assert _at(shape, second)[0] == shape.burst_users + + def test_shape_ends_the_run_itself(self) -> None: + shape = ClassroomBurstShape() + assert _at(shape, shape.total_seconds) is None + assert _at(shape, shape.total_seconds + 1) is None + + def test_total_seconds_accounts_for_every_burst(self) -> None: + shape = ClassroomBurstShape() + assert shape.total_seconds == ( + shape.warmup_seconds + shape.bursts * shape._cycle_seconds + ) + + +class TestSpawnRate: + def test_spawn_rate_clears_the_gap_within_the_burst_window(self) -> None: + # This is the number that makes it a burst. If it were low enough that + # the ramp outlasted the window, the test would be measuring a ramp + # while claiming to measure a spike. + shape = ClassroomBurstShape() + gap = shape.burst_users - shape.baseline_users + assert shape._spawn_rate * shape.burst_seconds >= gap * 0.95 + + def test_spawn_rate_is_never_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_BURST_BASELINE_USERS", "10") + monkeypatch.setenv("AGENTCORE_LOAD_BURST_USERS", "11") + monkeypatch.setenv("AGENTCORE_LOAD_BURST_SECONDS", "600") + assert ClassroomBurstShape()._spawn_rate >= 1 + + +class TestValidation: + def test_burst_below_baseline_is_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_BURST_BASELINE_USERS", "50") + monkeypatch.setenv("AGENTCORE_LOAD_BURST_USERS", "10") + with pytest.raises(ValueError, match="must be >="): + ClassroomBurstShape() + + def test_zero_burst_window_is_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_BURST_SECONDS", "0") + with pytest.raises(ValueError, match="BURST_SECONDS"): + ClassroomBurstShape() + + def test_non_integer_env_is_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_BURST_USERS", "lots") + with pytest.raises(ValueError, match="must be an integer"): + ClassroomBurstShape() + + def test_env_overrides_are_honoured(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCORE_LOAD_BURST_USERS", "250") + monkeypatch.setenv("AGENTCORE_LOAD_BURST_COUNT", "1") + shape = ClassroomBurstShape() + assert shape.burst_users == 250 + assert shape.bursts == 1 diff --git a/tests/load/tests/test_sse.py b/tests/load/tests/test_sse.py new file mode 100644 index 000000000..9777d4ddc --- /dev/null +++ b/tests/load/tests/test_sse.py @@ -0,0 +1,104 @@ +"""Tests for the SSE reader. + +The sequences here are copied from the literal ``yield`` statements in +``agents/main_agent/streaming/stream_coordinator.py`` so a protocol change on +the server shows up as a failure here rather than as mystery timeouts in a run. +""" + +from __future__ import annotations + +from agentcore_load.sse import iter_sse_events + + +def _events(raw: str): + return list(iter_sse_events(iter(raw.split("\n")))) + + +def test_parses_a_complete_turn() -> None: + raw = ( + 'event: message_start\ndata: {"role": "assistant"}\n\n' + 'event: content_block_start\ndata: {"contentBlockIndex": 0, "type": "text"}\n\n' + 'event: content_block_delta\ndata: {"contentBlockIndex": 0, "type": "text", ' + '"text": "Hello"}\n\n' + 'event: content_block_stop\ndata: {"contentBlockIndex": 0}\n\n' + 'event: message_stop\ndata: {"stopReason": "end_turn"}\n\n' + "event: done\ndata: {}\n\n" + ) + events = _events(raw) + + assert [event.name for event in events] == [ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_stop", + "done", + ] + assert events[-2].data["stopReason"] == "end_turn" + + +def test_first_token_is_the_first_content_delta() -> None: + raw = ( + 'event: message_start\ndata: {"role": "assistant"}\n\n' + 'event: content_block_start\ndata: {"contentBlockIndex": 0, "type": "text"}\n\n' + 'event: content_block_delta\ndata: {"text": "Hi"}\n\n' + ) + first = next(e for e in _events(raw) if e.is_first_token_candidate) + assert first.name == "content_block_delta" + assert first.text == "Hi" + + +def test_text_accumulates_only_from_string_payloads() -> None: + # `content_block_delta` also carries tool-input deltas, where `text` is + # absent. Those must not be counted as response characters. + raw = ( + 'event: content_block_delta\ndata: {"text": "abc"}\n\n' + 'event: content_block_delta\ndata: {"input": "{\\"q\\":1}"}\n\n' + ) + assert sum(len(event.text) for event in _events(raw)) == 3 + + +def test_keepalive_comments_are_ignored() -> None: + # app-api emits SSE comments so intermediaries do not cut an idle stream. + raw = ": keepalive\n\nevent: done\ndata: {}\n\n" + assert [event.name for event in _events(raw)] == ["done"] + + +def test_side_channel_after_message_stop_is_still_parsed() -> None: + # oauth_required and friends legitimately arrive after message_stop, which + # is why `done` is the terminator rather than message_stop. + raw = ( + 'event: message_stop\ndata: {"stopReason": "end_turn"}\n\n' + 'event: oauth_required\ndata: {"provider": "google"}\n\n' + "event: done\ndata: {}\n\n" + ) + assert [event.name for event in _events(raw)] == [ + "message_stop", + "oauth_required", + "done", + ] + + +def test_malformed_data_yields_empty_dict_instead_of_raising() -> None: + raw = "event: metadata\ndata: {not json\n\n" + events = _events(raw) + assert events[0].name == "metadata" + assert events[0].data == {} + + +def test_unterminated_final_event_is_still_emitted() -> None: + # A stream cut without its trailing blank line must not lose the last + # event, or a completed turn would be reported as never finishing. + raw = "event: done\ndata: {}" + assert [event.name for event in _events(raw)] == ["done"] + + +def test_error_turn_exposes_stop_reason() -> None: + raw = 'event: message_stop\ndata: {"stopReason": "error"}\n\nevent: done\ndata: {}\n\n' + events = _events(raw) + assert events[0].data["stopReason"] == "error" + + +def test_multiline_data_is_joined() -> None: + raw = 'event: metadata\ndata: {"a":\ndata: 1}\n\n' + assert _events(raw)[0].data == {"a": 1} diff --git a/tests/load/uv.lock b/tests/load/uv.lock new file mode 100644 index 000000000..1bbee6856 --- /dev/null +++ b/tests/load/uv.lock @@ -0,0 +1,980 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "agentcore-load" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "locust" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "locust", specifier = "==2.46.4" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = "==8.4.2" }, + { name = "ruff", specifier = "==0.14.5" }, +] + +[[package]] +name = "bidict" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f2/8d2dd8276ca05e1f5157b6a0d34efb2f585f47a0fbed61e8aad04b221f0b/bidict-0.24.1.tar.gz", hash = "sha256:4dca6c17f0b01700e9f24359daa5ebabf7be022d99f4cb2a257b6af2a5076c88", size = 30818, upload-time = "2026-08-25T23:45:52.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/53/2a3c7d562271ec6b6e38e7216b3104899f8aa4180c0713cb8aaf69e29cd5/bidict-0.24.1-py3-none-any.whl", hash = "sha256:fd3eaa737917d8a14f4baa391670c433c4e3f6f5fd2cd99d4bf436437f432364", size = 36175, upload-time = "2026-08-25T23:45:51.096Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "configargparse" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + +[[package]] +name = "gevent" +version = "26.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/eb/5f2db8013f1a4a6df2c23201f384a066f13ff5764a9f62a608c8a50ac8cc/gevent-26.8.0.tar.gz", hash = "sha256:96039f41bbde6dcd72559e5ffbd408a04f46774b47d991d4cf032da8fa79e5a0", size = 6625998, upload-time = "2026-08-10T18:02:28.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/b2/6fb10373605926a5cb65fedd11e3be2595889fa19c3c29587644271ffd4c/gevent-26.8.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:8dde9ed35e3b8bc2c25dfa11c2c770ac526642bda6dc3b25ee7990224de58c90", size = 2990229, upload-time = "2026-08-10T16:57:16.417Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/36355bb730c1390ddd235c5f5a216e8a282dde6bd224065ce5ea83193f71/gevent-26.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7562e4ec86cbb89e457eac78aaceb0266459c24b033b6946322463f121d801e8", size = 1812656, upload-time = "2026-08-10T17:58:50.884Z" }, + { url = "https://files.pythonhosted.org/packages/af/5c/0d17c59b3d2ca04fa0841454bf57c31891056233885a433e137e423cadd6/gevent-26.8.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:e8bc413b9bcf7cd851b34b0f58608171f7e4ac76246175eebc15be1a3119f705", size = 1911448, upload-time = "2026-08-10T17:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/78/fb/850ec40b05b0e63e4888403ebb5a8c7ba04402731fbd6a7604129f85fe65/gevent-26.8.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:4b89f12461c5aa0c7d98bebf9403db0bb6a7ff2f599420145a64c20f49fe1038", size = 1861150, upload-time = "2026-08-10T17:54:28.717Z" }, + { url = "https://files.pythonhosted.org/packages/ff/62/dfe2b89f28b7e4d99165d7b3fd239eac813dc5c03b6c06c7e58c5aaceda2/gevent-26.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:7644970428fcc32011ac562b5e6b646a505296a109165e2bc16f8b825ab4a809", size = 2141003, upload-time = "2026-08-10T17:19:22.988Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3d/1afa2c2503ce9470b33c23098722c5082e47f22efd4464fcc1ff8cc0e2e2/gevent-26.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e403966ca6a3f556579f0a54eabd06ad6cc5b98d66488bc9d97a34989ad8c480", size = 1825210, upload-time = "2026-08-10T17:42:39.604Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7f/804723cc1b0ba7407c7abb9dc0d06d9cd9283f67ab22b8375cd348eb21ae/gevent-26.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1494690813f476eca534ec8eca0a470013910279e6c019ccdad2ed543db391dd", size = 2167610, upload-time = "2026-08-10T17:23:51.043Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/474a9f5401a79ec6f5595c31f126f2fc00377b55596e39d5e623311f6364/gevent-26.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:9fd575d0091074028512437e08b77d05e265619b18becb22f87a827274262a12", size = 1692381, upload-time = "2026-08-10T16:59:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/92/3e/6dbbcadfd2b45e996636f0e72ec59891c4788d05c6300851687b91f5d977/gevent-26.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:097c9ed26102f62dc1e7d974274752ce462b98ad01ac55d7b1044fcba3429a86", size = 1565147, upload-time = "2026-08-10T16:59:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/61/cb/1bed6675f6cba42bfe23c38eacf482e08dfaa7af251cb0cddfcbb084b757/gevent-26.8.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e0f6a96cd5f9ad8f1f91d5d56d3e5534e15682b4a0abecd8396a18b836296426", size = 3006150, upload-time = "2026-08-10T16:56:41.378Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/bb4f3272d419bc785ce6a39440f4d157a6f7c154fef93c77444579ee5f43/gevent-26.8.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:113d12a2d4276047980e491cc5856388954642eb555479c6962f52fb181eb1df", size = 1819363, upload-time = "2026-08-10T17:58:52.056Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/351a5c6890804e39a109c7df8aa8f17f953d2f0acf8215e040670a0e573a/gevent-26.8.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:3ea7d0ff714ac9c634bfaaa3cfb25a5fcf7a272df47cad2261642323b16a3266", size = 1916853, upload-time = "2026-08-10T17:48:12.677Z" }, + { url = "https://files.pythonhosted.org/packages/80/81/a37201ced7b96eeba4554a1e656d040ad808f254a9f3d7b94f8ac4cfa82a/gevent-26.8.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:1bee3c0cb1aa2cee3369de46f8d952d10310166d7b4a744ae64a32ee1e14a1f5", size = 1865982, upload-time = "2026-08-10T17:54:30.173Z" }, + { url = "https://files.pythonhosted.org/packages/30/6d/e70113648ee1041070e6190389796414dd70b8cb86ff5ec8a9762284cb1a/gevent-26.8.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:64bc5c3302b02f9ad012243173e13705711dbbcc7590443d974f2961154d7077", size = 2147279, upload-time = "2026-08-10T17:19:24.24Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/1af5f1910d5534bba338d954e77f401e167189c7af8b621104307e2e7e16/gevent-26.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e544cba5810ec64056d78f0d3c79ceeafc91ac612b4e9008134645bc437dd885", size = 1832674, upload-time = "2026-08-10T17:42:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/c1/23/bc09e7f2a0dc699269d5dd03a4860afa80f8d29c1f28a18859ebd06d4ab1/gevent-26.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3445b3a8a51fcb7b881485b1089f7d64ca057aa921a78f146d702853650f958d", size = 2173988, upload-time = "2026-08-10T17:23:52.475Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/44ebdb32eb5050c367c4ccf6d43627bf875847ef65d8455beff52044846c/gevent-26.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fe56814f6d36d8fdfe0254909388fa58ccb61e7945d2f031a2dbd81f90ced4c", size = 1716724, upload-time = "2026-08-10T17:01:37.23Z" }, + { url = "https://files.pythonhosted.org/packages/8d/71/6708a3aae223a326b648a01beb5244caa562d75f6515528a8241260763f3/gevent-26.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:5914bab0ebe7fbd6077d703b61b2e74afec0436487f29b8154302c61f4d58502", size = 1594414, upload-time = "2026-08-10T17:00:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/a7/18/42a8129bdfe7285f35be21e6737a135462cb723a30952f06cb2203ecc8df/gevent-26.8.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:9afd9fbccbcea0b803d554b9e7bac75382e919aa07a5cb98d04edeba10c6cf77", size = 3009619, upload-time = "2026-08-10T16:59:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ff/f540c92d4c6ff3af5e60e990c7f417d1419a5a2aee5f5047612e708221c7/gevent-26.8.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:cd225e954d57e7d8a994a8d152f0d76609c73fa701a3e27293cc46675b2dce77", size = 1821950, upload-time = "2026-08-10T17:58:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/ca/eb/b40a8b9df1d4955cae3f600db416d6a8abb73f5f31d8a315cf2acf225462/gevent-26.8.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:8cb1402c0c7bdbd6d772fd5eb700b98b31ce0d8f613f68823f32cce5ec956d8c", size = 1921096, upload-time = "2026-08-10T17:48:14.477Z" }, + { url = "https://files.pythonhosted.org/packages/74/e4/59fc824207ac7f63ac83af7a7bed2707d6768488f1ae2c6c85794d614570/gevent-26.8.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:242d5e3622a39236f57a4e740c5480e86b6bc15c3a790e997b8cc99bee34ef27", size = 1868863, upload-time = "2026-08-10T17:54:31.624Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8b/cb001b1906ce78c63a252ad5baaebba745ca819bd7239ff60417fbc0d24a/gevent-26.8.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:b13173a992de43e92d15c6ac318f8ae019cf08eec15a78f99a0d4a917837052a", size = 2149188, upload-time = "2026-08-10T17:19:25.356Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c6/3085b52ec0ecc8173de40fca1f5faf8d02761288e1712ffb34628cfab214/gevent-26.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f431af3f2737ae01cf1c5a303f193cc2a8f726521e997ea8baf2d8567c6fe07c", size = 1835672, upload-time = "2026-08-10T17:42:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/fa/99/ee5722f7d51ee4bd09396629802ae90c9e0bebef0e7938a4edf08eb92749/gevent-26.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1d8b76e525d7e301db83d8124a27700c55ad23754f3efbbf15c7051d6fa19853", size = 2177315, upload-time = "2026-08-10T17:23:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cb/c2fea129f29ca114dc63f53178337845ba63532e07979c10fd70635a6dde/gevent-26.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:b16931069d0044a23a566d16dc5787122e858fa9d591c81500b6e6ad2148cfec", size = 1716882, upload-time = "2026-08-10T17:00:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/29/f4/b4de17304026cbd6386d427bc5ece10ec65015e9bfd66e14672350ee5dfb/gevent-26.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:42b8ed34f7aff517fac448ab57084e5e39d6a84a2b6c2b709223d5751c85e657", size = 1594517, upload-time = "2026-08-10T17:00:47.046Z" }, +] + +[[package]] +name = "geventhttpclient" +version = "2.3.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "certifi" }, + { name = "gevent" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/ff/cb3db11fca4223b2753ae170d1a09c9d32bfbfa3e8d4a6181324db686830/geventhttpclient-2.3.9.tar.gz", hash = "sha256:16807578dc4a175e8d97e6e39d65a10b04b5237a8c55f7a5ef39044e869baeb8", size = 84353, upload-time = "2026-03-03T08:09:03.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/0c/ec3e7926e5a24780ad0f2d422799966f2f13342c793ed9f37f0c03282f58/geventhttpclient-2.3.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9d0568d38cf74cecd37fd1ef65459f60ecd26dbc0d33bc2a1e0d8df4af24f07d", size = 70144, upload-time = "2026-03-03T08:08:19.932Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/d28f76482880f9233de07fb9422db26b983a901cad4670bba8bc1170f988/geventhttpclient-2.3.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:02e06a2f78a225b70e616b493317073f3e2fddd4e51ddfc44569d188f368bd8d", size = 51779, upload-time = "2026-03-03T08:08:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/35/ff/930be8f0e4f84d1b229b1ec394463ea36701991d888f4856904e292a6b0b/geventhttpclient-2.3.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eec8e442214d4086e40a3ae7fe1e1e3ecbc422157d8d2118059cf9977336d9f", size = 51516, upload-time = "2026-03-03T08:08:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ac/952c51392527f707c1f08401d0b477cdd1840a487dffa6e9fce444d54122/geventhttpclient-2.3.9-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a18b28d2f8bc7fcfc721227733bccb647602399db6b0fd093c00ff9699717b74", size = 115412, upload-time = "2026-03-03T08:08:22.615Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/1b61f8dae1efb670f7728cd727c35ff294b89af727db268f9e2d90102a97/geventhttpclient-2.3.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b16e30dbbc528453a4130210d83638444229357c073eb911421eb44e3367359", size = 116088, upload-time = "2026-03-03T08:08:23.474Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/941c05d483fe8a95672f8f39e7410292f4b617020d1d595b88da5660b132/geventhttpclient-2.3.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06df5597edf65d4c691052fce3e37620cbc037879a3b872bc16a7b2a0941d59a", size = 122068, upload-time = "2026-03-03T08:08:24.495Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/84400d58934f774cef259c8b49292542313c02224c2f11b1b116d720b464/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:47a303bcac3d69569f025d0c81781c5f0c1a48c9f225e43082d1b56e4c0440f8", size = 112054, upload-time = "2026-03-03T08:08:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/12/ae/12821cad292235d4db8532f58c8bc93db4211862845bf76a4c06e6ed1416/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e73b25415e83064f5a334e83495d97b138e66f67a98cfcad154068c257733973", size = 118837, upload-time = "2026-03-03T08:08:26.816Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d3/34e80569f3563eb26f5d7bb971677de0b53b16d720f87373cc7aeee51c04/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98ff3350d8be75586076140bde565c35ccdd72a6840b88f94037ec6595407383", size = 112643, upload-time = "2026-03-03T08:08:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/94ace94281e40f7258ba4e7166ae846394d2a673dbe47a0a255eb0d53ca8/geventhttpclient-2.3.9-cp313-cp313-win32.whl", hash = "sha256:af7931f55522cddedf84e837769c66d9ceb130b29182ad1e2d0201f501df899f", size = 48741, upload-time = "2026-03-03T08:08:28.507Z" }, + { url = "https://files.pythonhosted.org/packages/89/67/15b1ba79dfbab515c0d42a01b6545adef7dad00968eaa89ec21cca030c2e/geventhttpclient-2.3.9-cp313-cp313-win_amd64.whl", hash = "sha256:14daf2f0361f19b0221f900d7e9d563c184bb7186676e61fe848495b1f2483d3", size = 49371, upload-time = "2026-03-03T08:08:29.319Z" }, + { url = "https://files.pythonhosted.org/packages/16/9f/57d5acd0d95417a29661dfa91a8657be8026a9df17cafc6ba4f20bc2a687/geventhttpclient-2.3.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c06e243de53f54942b098f81622917f4a33c16f44733c9371ea98a2cd5ce12e", size = 70423, upload-time = "2026-03-03T08:08:30.106Z" }, + { url = "https://files.pythonhosted.org/packages/29/8b/ad6eb43b136fdb2f4954dc21073911d7703ea95fd88a3cc7512714508ce3/geventhttpclient-2.3.9-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:549155d557de403612336ca36cd93a049e67acbf9a29e6b6b971d0f4cb56786d", size = 51902, upload-time = "2026-03-03T08:08:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/2d2cfd9dd9ae0a6d4138b8a88f0b4524657a48a7c81ead6986a3e955deda/geventhttpclient-2.3.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b463324d5fde983657247b2faea77f8f8a40f3f7ac0c2897a2fe3afa27d610", size = 51564, upload-time = "2026-03-03T08:08:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f3/4585fabea4f45c4c21cd128d61ebbf43a78c73d520c70471734d41177b1d/geventhttpclient-2.3.9-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e1ac3a39e3c4ae36024ddf1694eb82b0cc22c4516f176477f94f98bcd56ce6cf", size = 115449, upload-time = "2026-03-03T08:08:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e8/d7d82f527c632cbdeffa34858557db3da238f68f2fbb9bd80f2ec2c64510/geventhttpclient-2.3.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3d24480c3a2cc88311c41a042bc12ab8e4104dad6029591ecbf5a1e933e8a44", size = 116152, upload-time = "2026-03-03T08:08:33.941Z" }, + { url = "https://files.pythonhosted.org/packages/ea/63/25ee53c3efa9ded9976e4f5ac8c6f8e8cef941bbbc847290e7f5c0254c40/geventhttpclient-2.3.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b244adcbf5814a29d5cea8b2fc079f9242d92765191faa4dc5eccc0421840ae", size = 122145, upload-time = "2026-03-03T08:08:34.809Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8c/15c5d71e6011f317f7decb26fd15e1e6caf780b09297af3018599311e6df/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83dc6f037a50b7d2dc45af58a7e7978016a06320a5f823d1bd544c85d69f2058", size = 112134, upload-time = "2026-03-03T08:08:35.716Z" }, + { url = "https://files.pythonhosted.org/packages/02/5b/fd7b17c37a9f9002a5fd8d690c97ada372393fcfb9358dd62026e089ae96/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:caf8779ca686497e0fab1048b026b4e48fb14fb9e88ddbfd14ca1a1a4c4bfa89", size = 118879, upload-time = "2026-03-03T08:08:36.953Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/c1559f43ef56100d64bf1b227844bf229101fdb58b556e2336b4307bda0d/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4efebba798c7f585aa1ceb9aba9524b12ebc51b26ad62de5234b8264d9b94d", size = 112593, upload-time = "2026-03-03T08:08:37.842Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a61a9cb76feede0d4429154d391c275debde78b6602f52c95aeed6dce1ff/geventhttpclient-2.3.9-cp314-cp314-win32.whl", hash = "sha256:7b60c0b650c77d2644374149c38dfee34510e88e569ca85f38fe15f40ecaea1c", size = 49390, upload-time = "2026-03-03T08:08:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/fd/05/b8d71edd82c9b07b9be0c3e5d6faf94583c6a098e2e9e5ee2b14a6312c5e/geventhttpclient-2.3.9-cp314-cp314-win_amd64.whl", hash = "sha256:c4d5e1b9b1ac9baab42a1789bbfae7e97e40e8e83e09a32b353c6eb985f36071", size = 49881, upload-time = "2026-03-03T08:08:40.228Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/2f7f8f63968d26d7233fb9b9e5b1a5015989b90f95e997e9dc98283b0a86/geventhttpclient-2.3.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ae44cec808193bb70b634fabdfdd89f0850744ace5668dc98063d633cf50c417", size = 70812, upload-time = "2026-03-03T08:08:41.073Z" }, + { url = "https://files.pythonhosted.org/packages/5f/89/7887f802adee5990c10dd9c44b20a3205e046773061266ce5cffb99e30b9/geventhttpclient-2.3.9-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:53977ca41809eaef73cf38af170484baa53bde5f16bafbca7b77b670c343f48f", size = 52087, upload-time = "2026-03-03T08:08:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/5d/07/23cc505111abb65cb5a68e5cd123b1ffc1ad7893a1bc46945b9ed3d03245/geventhttpclient-2.3.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0f66a33c95e4d6d343fc6ace458b13c613684bf7cfd6832b61cc9c42eaf394f3", size = 51772, upload-time = "2026-03-03T08:08:42.926Z" }, + { url = "https://files.pythonhosted.org/packages/d9/97/461fd5c73858b2daaaba2ecefd2ff64aa8f2242c48c939e75caba9ec3cb2/geventhttpclient-2.3.9-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cfe23d419aa676492677374bdd37e364c921895d1090a180173be5d5f87f82b9", size = 118329, upload-time = "2026-03-03T08:08:44.07Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/0ede3d90ab92a105f24b758b0bfb2d5e7f34c017d22d76d87524e75e93cc/geventhttpclient-2.3.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3b279da39ad3eee69a5df9e1b602f87bcd2cec7eb258d3cc801e2170682383", size = 119974, upload-time = "2026-03-03T08:08:45.231Z" }, + { url = "https://files.pythonhosted.org/packages/98/8f/0ef02946bbbbd91ba4c3da99657d90e250c00409710ed377e4e4540b90c3/geventhttpclient-2.3.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38535589a564822c64d1b4c2a5d6dcc27159d0d7d76500f2c8c8d21d9dd54880", size = 125764, upload-time = "2026-03-03T08:08:46.446Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/d8f385fd6a3538cf1fd57a3fd47b133fba2e32c6be86e75805117d96ff1f/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a6436cd77885a8ef7cdc6d225cddd732560a17e92969c74e997836cf3135baa0", size = 115599, upload-time = "2026-03-03T08:08:47.393Z" }, + { url = "https://files.pythonhosted.org/packages/cb/55/ec651647ee2f7fdfee8d7a75ba682064e0e5012696f9aa83c0392d54fdeb/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5000c9fb0553818c4e4c1de248ee4e9a56de0a245a30ef76b687542a935f4645", size = 122254, upload-time = "2026-03-03T08:08:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/77/7d/20606d1a4ae085eb3935e4d3625e7208911d0f1a0006c9fd962d88254d92/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:52516d5c153fcef0d3d2447e533244dc6360e8c2a190b958861137db6f227605", size = 115383, upload-time = "2026-03-03T08:08:49.454Z" }, + { url = "https://files.pythonhosted.org/packages/85/16/d20ac6ac73d63fe326ffe357a8e91c4f43b9e790faeeb9b15774eecf2550/geventhttpclient-2.3.9-cp314-cp314t-win32.whl", hash = "sha256:14eaa836bde26a70952e95ca462018f3a47c1c92642327315aa6502e54141016", size = 49749, upload-time = "2026-03-03T08:08:50.339Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/95d1ed1ace7902d8e1ce698db31931cb87d4abe621ec2df24e69daf49ae9/geventhttpclient-2.3.9-cp314-cp314t-win_amd64.whl", hash = "sha256:b9bbcbc7d5d875e5180f2b1f1c6fa8e092ef80d9debfb6ba22a4ec28f0565395", size = 50300, upload-time = "2026-03-03T08:08:51.482Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "locust" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-login" }, + { name = "gevent" }, + { name = "geventhttpclient" }, + { name = "msgpack" }, + { name = "psutil" }, + { name = "pytest" }, + { name = "python-engineio" }, + { name = "python-socketio", extra = ["client"] }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "pyzmq" }, + { name = "requests" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/a1/6e2e204a14048fb49fcd4ff36ef7477f9188029f791a6d9b1d0905944e6a/locust-2.46.4.tar.gz", hash = "sha256:a5a5daf041bcd807053bfd86d8062740c76530beb47a6e28a43b9701a3effab9", size = 1478486, upload-time = "2026-08-24T09:03:45.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/51/6bc797d57628be30b45b207e25b50a45df842e2a887e0c4ae4776a60a4d1/locust-2.46.4-py3-none-any.whl", hash = "sha256:e48402a5ae3d3a5821ec38847c282f56cee810ff445bb6f887bc2b09f86f3edf", size = 1498060, upload-time = "2026-08-24T09:03:43.57Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" }, + { url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" }, + { url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/3e92c403346652cabd08cb8faceef847bae917ea3b3c81b64a5b6d09ed41/msgpack-1.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04", size = 84315, upload-time = "2026-08-27T10:02:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/8efe6dd96a12ab043930cb4cffb40b6e7f061491d6ec7a3d2b75ef1fda42/msgpack-1.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77", size = 84634, upload-time = "2026-08-27T10:02:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/996573095bf7b038c04dd65ddbc4f1a4d381b0f7a44ff9186f3c7b8325c2/msgpack-1.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe", size = 404194, upload-time = "2026-08-27T10:02:44.096Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4e/46f5a5d949dbd054dab60cb15aac7ac6ae6774c134532893414689bf2f53/msgpack-1.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f", size = 412343, upload-time = "2026-08-27T10:02:45.747Z" }, + { url = "https://files.pythonhosted.org/packages/da/e8/739a94197358a313307e6e9e7d8d22ef66add39222de911a44161aa96920/msgpack-1.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea", size = 372620, upload-time = "2026-08-27T10:02:47.578Z" }, + { url = "https://files.pythonhosted.org/packages/03/d4/09b92e1fcdccea9466bfae45455367ac52362ae445d96a602e51b7a8df73/msgpack-1.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b", size = 394603, upload-time = "2026-08-27T10:02:49.172Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/d11bd6f258a60703dcdc7a3772818ad0c2f602ee4c2acfb24088c6c3ebc3/msgpack-1.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5", size = 372666, upload-time = "2026-08-27T10:02:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/fbbbac0c6e5fbb9d51abc23e3b5fe8620f5c01e0588797cf664a623bb9e1/msgpack-1.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54", size = 410889, upload-time = "2026-08-27T10:02:52.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/60/8366558da954095e04e7fbc351f9387d87a682feaee9a235ceda966f794b/msgpack-1.2.2-cp314-cp314-win32.whl", hash = "sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248", size = 66774, upload-time = "2026-08-27T10:02:53.984Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3d/1ce873c8057c65e4fbb076ffe1c99c9ae39d90a00a2540d7b06c652a292f/msgpack-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc", size = 73424, upload-time = "2026-08-27T10:02:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/d5/55/e36f2a33e38657f33850d74e0bf256838a0d45802c298cc501a32bffcc08/msgpack-1.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8", size = 67657, upload-time = "2026-08-27T10:02:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/64/58/7e764b957bae80ae281a9cb28761068c8bae8d5c6ac0873e43cc69d176c7/msgpack-1.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650", size = 86594, upload-time = "2026-08-27T10:02:57.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f0/250f5985b6ee533e60d357571a808aaae03c54118294dc3db7158e27feb1/msgpack-1.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c", size = 87374, upload-time = "2026-08-27T10:02:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2c/126ec8f187877c5f688631c543d1d3a3d75b2e66b83fb9de3ed7c13a39b6/msgpack-1.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3", size = 428157, upload-time = "2026-08-27T10:03:00.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d2d81d50aaedb14147d01f22094185794db3ad8a8791b60afacba0627c89/msgpack-1.2.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3", size = 426669, upload-time = "2026-08-27T10:03:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fc/f7d484ee5b572719608e7ffad569bea22ff11309a96ca2fae85eec94226b/msgpack-1.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22", size = 380625, upload-time = "2026-08-27T10:03:04.244Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c4/b924cbd5516676f4e612329f18602a833bd055ffbe27f808eeba0f01bfea/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839", size = 411328, upload-time = "2026-08-27T10:03:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/27/9d/0c1d9683a951a80f270c3b7dac1022c18b9307617344dd44d904135d5e12/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929", size = 377892, upload-time = "2026-08-27T10:03:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/bf22338cdd22e0b40c8f28468cea5f3d9c320244c095d8303364bc012c41/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7", size = 419426, upload-time = "2026-08-27T10:03:09Z" }, + { url = "https://files.pythonhosted.org/packages/7d/42/6d02c19a01abd8d7ce817c321d2ee6af1a8e24d584dca619d1b6576a83bf/msgpack-1.2.2-cp314-cp314t-win32.whl", hash = "sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e", size = 71810, upload-time = "2026-08-27T10:03:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/fda3a204415dab0a8c0db5461ef7205416ea52bd8581c5cafd361be07f3b/msgpack-1.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36", size = 78919, upload-time = "2026-08-27T10:03:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/63/d4/4b4b0ef25a86deca91feaf7252ca885ba4f2ada40461379120122a04fe96/msgpack-1.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f", size = 71925, upload-time = "2026-08-27T10:03:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/4b44bc8f3243ef8cf9cb5368c17a299d45b9df858f6dfdd98a0482dbbb37/msgpack-1.2.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5", size = 84293, upload-time = "2026-08-27T10:03:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/80/05/c992bb65744665a41b5bf531fc0e1619bae0901f57738228ded90023c151/msgpack-1.2.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986", size = 84490, upload-time = "2026-08-27T10:03:16.12Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bf/7f53b9e6709a4df7f9b9b81dc65f9dfaa32caf65bee94986ec2cb8fa07f1/msgpack-1.2.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516", size = 405332, upload-time = "2026-08-27T10:03:17.692Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5a/305c4dca14b50d0b51fb88ef04ec125b8f0be3e2ce730dcc62dbaa651cc5/msgpack-1.2.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21", size = 416798, upload-time = "2026-08-27T10:03:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/a645102b4cdfd9a94201cac4e900e9c1429fc16d86aa311c06eef82528c9/msgpack-1.2.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9", size = 377312, upload-time = "2026-08-27T10:03:20.928Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/c56d8d086d3fb1077bb48092b158b5ea2eee08b279e10c191275f13bc980/msgpack-1.2.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a", size = 395182, upload-time = "2026-08-27T10:03:22.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b5/3d46ba367a565e536d8d2a61eebcee71b1dc803da3ce74a22313b573d6fa/msgpack-1.2.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5", size = 377945, upload-time = "2026-08-27T10:03:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2c/d5d2df273ed5306357da25b69400fd8d7a53c4d87d8976604b677484d61c/msgpack-1.2.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b", size = 413341, upload-time = "2026-08-27T10:03:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fb/32613bced3cad47b40b1b73dd04d687121349d83f748efc2575929121903/msgpack-1.2.2-cp315-cp315-win32.whl", hash = "sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf", size = 66730, upload-time = "2026-08-27T10:03:27.294Z" }, + { url = "https://files.pythonhosted.org/packages/74/56/d86171f7251015e9312e5a7f9fdd4cf89752fc2114b88fed453d2a040c66/msgpack-1.2.2-cp315-cp315-win_amd64.whl", hash = "sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff", size = 73477, upload-time = "2026-08-27T10:03:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/13/1a/56b90f6defef61700b86baca3637c15f62ac0f9b21ab0f16613ab9d1f101/msgpack-1.2.2-cp315-cp315-win_arm64.whl", hash = "sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808", size = 67660, upload-time = "2026-08-27T10:03:29.895Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/12751ca0d8ec874701b54c392c2b19f51af8dd1de40a92a10e356f0aaf58/msgpack-1.2.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8", size = 86462, upload-time = "2026-08-27T10:03:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/cf6d12a3d709fe5f9771dd917c35e6ebcd55597a5b792287382fde056c95/msgpack-1.2.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84", size = 87412, upload-time = "2026-08-27T10:03:32.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0d/0aac5752d1708dcb458f8754db34a4999514db3df2d2b798b9381293f638/msgpack-1.2.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b", size = 422057, upload-time = "2026-08-27T10:03:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/81/30/70f281a3685b04aaf235a5237da11b978a02a865a5a479186205177ad676/msgpack-1.2.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782", size = 422696, upload-time = "2026-08-27T10:03:35.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/f76e8425efb0aa38988cd778ae290bfa120491d80d26872d88bb52fedb3f/msgpack-1.2.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f", size = 376495, upload-time = "2026-08-27T10:03:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/0809aa9b52b2868f7d01862dc14073708f0440421a65197b48453480034c/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695", size = 404683, upload-time = "2026-08-27T10:03:38.87Z" }, + { url = "https://files.pythonhosted.org/packages/02/d2/4e5ac915ba120172d210ef00165c5e6276c8a65db3a4a5cf36e946b83e23/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23", size = 375087, upload-time = "2026-08-27T10:03:40.486Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e3/8051d53e5495c87c6cf27eb42fb680361017037f87f322bdaf525f71e4a2/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212", size = 414421, upload-time = "2026-08-27T10:03:42.308Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4e/13783aa7c17414d7186c72c49bc718366f75e49f0ea58d4f81cb63ac3187/msgpack-1.2.2-cp315-cp315t-win32.whl", hash = "sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc", size = 71790, upload-time = "2026-08-27T10:03:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/1d02994c7ae2603c98100984428ff0f67443572133bc18eca6058f732c1b/msgpack-1.2.2-cp315-cp315t-win_amd64.whl", hash = "sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d", size = 78766, upload-time = "2026-08-27T10:03:45.036Z" }, + { url = "https://files.pythonhosted.org/packages/60/54/89ed16e6f966a050dc78b0e94a545025211b07ce9f4bdfe07dff70c03fc2/msgpack-1.2.2-cp315-cp315t-win_arm64.whl", hash = "sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754", size = 71819, upload-time = "2026-08-27T10:03:46.375Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/65/f8bae11b228647e2e2f45b63dec7448efaddb7cb51f529de1fdba69e63b5/python_engineio-4.14.0.tar.gz", hash = "sha256:eaa1e386baf9c2c7959eef7f9d9165c5ea910c5b392f5316e78d29ed073cb43d", size = 80863, upload-time = "2026-08-30T19:52:01.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/de/07cfd386974c2a26a7bde41f2111be29bbfc92b9ea0bb76694415a4a1a78/python_engineio-4.14.0-py3-none-any.whl", hash = "sha256:9f0fe275fb7d67bfc1a632421adf22949fd4843bd9c458c004b0a89cede302a2", size = 60291, upload-time = "2026-08-30T19:51:59.776Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/5e/87d6b547c87c6d64f4a05f5bfaf6f42e9b786561216434290fdaa83f8667/python_socketio-5.16.4.tar.gz", hash = "sha256:f7fa4a43cc8e687930b5c6e44d6e2efc2071eca4bef49b8bb3dc0827f7f92235", size = 128140, upload-time = "2026-08-06T23:11:21.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl", hash = "sha256:0eb9c7687e7fbf59e60d714fd62afba77dfaf8ef8a06a0bff05a86c351accc2f", size = 82098, upload-time = "2026-08-06T23:11:19.851Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "requests" }, + { name = "websocket-client" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/d0541596b48c5a19f85dcbea83d6673d8e91681cdf853eb194c31fc9766e/pyzmq-27.2.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:c551b9e2f86dc625fcb1a032c0d68042678caf96a8dd7c28796766b673bd5b52", size = 1127193, upload-time = "2026-08-20T19:06:56.545Z" }, + { url = "https://files.pythonhosted.org/packages/50/9f/8c7411bb283982d46e6d56dca6a095678c87eb0398daead12776d9881ac2/pyzmq-27.2.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:288cc790da0e3064a14a38ddc56ba169dada8c8af4cb86518db2bcbd380eedbb", size = 1166833, upload-time = "2026-08-20T19:06:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702", size = 1134452, upload-time = "2026-08-20T19:06:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8", size = 1167520, upload-time = "2026-08-20T19:07:01.283Z" }, + { url = "https://files.pythonhosted.org/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7", size = 1466289, upload-time = "2026-08-20T19:07:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f", size = 975868, upload-time = "2026-08-20T19:07:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1", size = 706054, upload-time = "2026-08-20T19:07:05.623Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853", size = 878984, upload-time = "2026-08-20T19:07:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd", size = 1697489, upload-time = "2026-08-20T19:07:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af", size = 2064236, upload-time = "2026-08-20T19:07:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103", size = 1917424, upload-time = "2026-08-20T19:07:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843", size = 591103, upload-time = "2026-08-20T19:07:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a", size = 670215, upload-time = "2026-08-20T19:07:15.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101", size = 582252, upload-time = "2026-08-20T19:07:16.582Z" }, + { url = "https://files.pythonhosted.org/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b", size = 1134295, upload-time = "2026-08-20T19:07:17.94Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9", size = 1167492, upload-time = "2026-08-20T19:07:19.476Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f", size = 1465992, upload-time = "2026-08-20T19:07:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6", size = 976118, upload-time = "2026-08-20T19:07:22.577Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44", size = 705968, upload-time = "2026-08-20T19:07:24.019Z" }, + { url = "https://files.pythonhosted.org/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b", size = 879011, upload-time = "2026-08-20T19:07:25.428Z" }, + { url = "https://files.pythonhosted.org/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d", size = 1697496, upload-time = "2026-08-20T19:07:26.821Z" }, + { url = "https://files.pythonhosted.org/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b", size = 2064347, upload-time = "2026-08-20T19:07:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012", size = 1917360, upload-time = "2026-08-20T19:07:29.693Z" }, + { url = "https://files.pythonhosted.org/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb", size = 591073, upload-time = "2026-08-20T19:07:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35", size = 670701, upload-time = "2026-08-20T19:07:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "zope-event" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/39/a8481b926e42c44a6fcc670904f8251469ec42edbff1ba066719ca1e7fb4/zope_interface-8.6.tar.gz", hash = "sha256:b40ef9b4873afb5d0dec02b8d2dfde1cf18c72337b60c99cb735961e0bac05c0", size = 257973, upload-time = "2026-08-20T11:18:08.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/01/860c4879f072968375ec82fabaa5d83256e6ad8d3dce9527b00931e54b10/zope_interface-8.6-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:add6e226c6568de6d0ea9f6abe6353072387afcf5f817610ea266495d0c1ee72", size = 212548, upload-time = "2026-08-20T11:17:29.161Z" }, + { url = "https://files.pythonhosted.org/packages/38/09/d4b7c46c020394c830e749c6c4ca6a2ca0b6defed6f4c2eeeb97116c7343/zope_interface-8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47030c08e39d690299e02973ac845d0f534121b3618efa9ce9599a512a1c97fa", size = 212536, upload-time = "2026-08-20T11:17:30.922Z" }, + { url = "https://files.pythonhosted.org/packages/4c/2d/5b4dbbe618b816f626f2a640fcd9911a461e3733a608c4043a8cc79c12b3/zope_interface-8.6-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c2bf932006229788d6bb41963dfc0345cba6ee24141a39316bd52a283a7d115f", size = 265203, upload-time = "2026-08-20T11:17:33.059Z" }, + { url = "https://files.pythonhosted.org/packages/79/96/c02befafb8e5d3c92898aa02fffca94d164830013fd0a50c4a652a728712/zope_interface-8.6-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:09522cdc6a77376bc36988b531db3b568c8cb0b6ca7286d8316aab283888770f", size = 270637, upload-time = "2026-08-20T11:17:35.167Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c4/d61b18724597ca62c1a3a753370fff7b76f43c01b44e9a13c18e2300eaf0/zope_interface-8.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:edf1bd7ed576319241b2b314eaa549cee3e3e0f81f46911086b387d03a303ad3", size = 270456, upload-time = "2026-08-20T11:17:37.146Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7a/96f177daba3f9d9d69d42659ae6c602c76b1d725e7dddff08ed49d9d02af/zope_interface-8.6-cp313-cp313-win_amd64.whl", hash = "sha256:00fd6a6da085beb90cdcdce6ed6e6973edf338d1ea63a807e213b1eb7013833d", size = 214763, upload-time = "2026-08-20T11:17:39.064Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/ce4a0ff71a1a93bd403c511307d70d32ae876e657d96063985f6672c92ec/zope_interface-8.6-cp313-cp313-win_arm64.whl", hash = "sha256:105da41198a1990b18d566bd30656a19064d4c313e4c0dd8f0dd9714026e47f1", size = 213621, upload-time = "2026-08-20T11:17:40.805Z" }, + { url = "https://files.pythonhosted.org/packages/3d/28/8ec94b15ebde2da2ebe643aac3c4238a55c2e95b746049721b50908ecafe/zope_interface-8.6-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:449727fc79f0b1317ec190632e13699b732d3f4704ea90c8e1339bb78e451bee", size = 212628, upload-time = "2026-08-20T11:17:42.566Z" }, + { url = "https://files.pythonhosted.org/packages/85/47/f06d4dbbc1464d9d4520b9c047d4a0f0062264eeb2c0b7fd1bec79a9327d/zope_interface-8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:81793c9b12816ac7f8b71b366be36b7025fcf7205ec4a236642b15a82cb027ef", size = 212627, upload-time = "2026-08-20T11:17:44.571Z" }, + { url = "https://files.pythonhosted.org/packages/1c/56/01f84b4e966a32088e9076b1e7b2afa310f52bf9b9a077d2958cf66e81aa/zope_interface-8.6-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a91eb220d9ae6aa6d746d6dac5b4db35b1417903301b3315ba3275b19570be0b", size = 266840, upload-time = "2026-08-20T11:17:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/40/2a644e32cd6f0516e7df1fc0c58e544a8cc11ba06b0d55d308519b02459d/zope_interface-8.6-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3f7f6da49911ffe75ae3f7a9a45619f205420cc6578aff02f8ca29ed1de10f14", size = 270145, upload-time = "2026-08-20T11:17:48.195Z" }, + { url = "https://files.pythonhosted.org/packages/1e/18/02ebd81feff11a2766159fcb49c5b773fef5ae4414c38fb19114aad9e961/zope_interface-8.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef15a2f6258f809334a19c1fcce64648813066ceebe3f3f6077871483fd0f50d", size = 270351, upload-time = "2026-08-20T11:17:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/26/56/0725e960cf581399b7f4136d5951f7d87bc659492e49db1794334f6c5153/zope_interface-8.6-cp314-cp314-win_amd64.whl", hash = "sha256:5ef166337880b0e78138bbd32fcbc5ab1da3337febe8d2a247f3690bcae3ede5", size = 215098, upload-time = "2026-08-20T11:17:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b3/7f864a6f9d9aebddceaac0a8c5cab0b450090f42fe316e48e6dd0c684478/zope_interface-8.6-cp314-cp314-win_arm64.whl", hash = "sha256:23ae710094fdcfcf715dae7054cd5abfefa4a527c5853d7b76ebb2541499c41a", size = 213759, upload-time = "2026-08-20T11:17:54.157Z" }, + { url = "https://files.pythonhosted.org/packages/19/b8/2f7a65ac046d3bb54e4a0664acfa152021804aa4101cbbec11526740c8af/zope_interface-8.6-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:a84ac0010f054f3516710804a0c22026b4b0d30085d7666cfc2f30545775bf99", size = 213631, upload-time = "2026-08-20T11:17:56.063Z" }, + { url = "https://files.pythonhosted.org/packages/12/c1/889dc114e9a9e8d59fec53facb71dd26345f60c504ad20fd17121af0449c/zope_interface-8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e36adea8ab93eb4d2076a47d5f4c7d7e1267eb9a4e33202da7ea71439a3bcaef", size = 213713, upload-time = "2026-08-20T11:17:57.998Z" }, + { url = "https://files.pythonhosted.org/packages/a9/96/ac48a6b7cfe972e4a9b0d7ec8b9f36a7956cc95d72029f0013ff096c55af/zope_interface-8.6-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5dbe120cfcfc8e6aed418f340c3d1ad4072253e17176503e363ddac27fcb2ac6", size = 294916, upload-time = "2026-08-20T11:17:59.952Z" }, + { url = "https://files.pythonhosted.org/packages/a2/54/4df4bb0b1aace2298386375ab2fb752378683b558d2db713e25c40a3e96a/zope_interface-8.6-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27e6de8e593736210d2a9f1bbf766a5653aa4819c184f864ab9d1f8bd3590a60", size = 300898, upload-time = "2026-08-20T11:18:02.224Z" }, + { url = "https://files.pythonhosted.org/packages/08/9c/0c8c80c1eeb62ac0c3ed1f51ad8cdd6da9373c53247c659c49f0ea29f742/zope_interface-8.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:66ab8c5d8820aa378968c16b7a3cb051aca342eafa649c9a363182f572d75ccb", size = 304684, upload-time = "2026-08-20T11:18:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/54/69/3afc11a58b9ea814fdfb9297a8c36d10871c1f0cc06d42c106282109b952/zope_interface-8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fcc86414ee0e6b77416de81b8dead5900719b3f71b7875d8d1f87ae4e166a11f", size = 215500, upload-time = "2026-08-20T11:18:06.259Z" }, +] diff --git a/tui/pyproject.toml b/tui/pyproject.toml index 859f222ea..3fbb09f0f 100644 --- a/tui/pyproject.toml +++ b/tui/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-tui" -version = "1.19.1" +version = "1.20.0" requires-python = ">=3.11" description = "Terminal client for the AgentCore Public Stack — streaming AI chat in your terminal" readme = "README.md" diff --git a/tui/src/agentcore_tui/__init__.py b/tui/src/agentcore_tui/__init__.py index 70e17e459..0b7a86229 100644 --- a/tui/src/agentcore_tui/__init__.py +++ b/tui/src/agentcore_tui/__init__.py @@ -7,6 +7,6 @@ from __future__ import annotations -__version__ = "1.19.1" +__version__ = "1.20.0" __all__ = ["__version__"] diff --git a/tui/uv.lock b/tui/uv.lock index 8b06354f5..964fac9c0 100644 --- a/tui/uv.lock +++ b/tui/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agentcore-tui" -version = "1.19.1" +version = "1.20.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -136,8 +136,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, @@ -145,8 +143,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, @@ -159,8 +155,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },