Skip to content
Type

Changelog

Every release across the Strands Agents SDKs ·RSS

SDK
Language
119 releases

  1. ✦ Features 7

    ⊘ Fixes 19

    Show 11 more fixes
    ⋯ 20 more changes (chores, docs, tests)
  2. ✦ Features 8

    ⊘ Fixes 27

    Show 19 more fixes
    ⋯ 27 more changes (chores, docs, tests)
  3. ✦ Features 4

    ⊘ Fixes 7

    ⋯ 25 more changes (chores, docs, tests)
  4. ✦ Features 14

    Show 6 more features

    ⊘ Fixes 5

    ⋯ 17 more changes (chores, docs, tests)
  5. ✦ Features 17

    Show 9 more features

    ⊘ Fixes 12

    Show 4 more fixes
    ⋯ 15 more changes (chores, docs, tests)
  6. ✦ Features 21

    Show 13 more features

    ⊘ Fixes 16

    Show 8 more fixes
    ⋯ 17 more changes (chores, docs, tests)
  7. ✦ Features 9

    Show 1 more features

    ⊘ Fixes 12

    Show 4 more fixes
    ⋯ 10 more changes (chores, docs, tests)
  8. ★ Highlights

    First TypeScript release cut from the unified harness-sdk monorepo. The itemized change list was omitted during the repository merge — see the release on GitHub for the full compare. Earlier TypeScript history continues below from the original sdk-typescript repository.

  9. ✦ Features 17

    Show 9 more features

    ⊘ Fixes 10

    Show 2 more fixes
    ⋯ 12 more changes (chores, docs, tests)
  10. Features

    Bedrock Service Tier Support — PR#1799

    Amazon Bedrock now offers service tiers (Priority, Standard, Flex) that let you control the trade-off between latency and cost on a per-request basis. BedrockModel accepts a new service_tier configuration field, consistent with how other Bedrock-specific features like guardrails are exposed. When not set, the field is omitted and Bedrock uses its default behavior.

    from strands import Agent
    from strands.models.bedrock import BedrockModel
    # Use "flex" tier for cost-optimized batch processing
    model = BedrockModel(
    model_id="us.anthropic.claude-sonnet-4-20250514-v1:0",
    service_tier="flex",
    )
    agent = Agent(model=model)
    # Use "priority" for latency-sensitive applications
    realtime_model = BedrockModel(
    model_id="us.anthropic.claude-sonnet-4-20250514-v1:0",
    service_tier="priority",
    )

    Valid values are "default", "priority", and "flex". If a model or region does not support the specified tier, Bedrock returns a ValidationException.

    Bug Fixes

    • Sliding window conversation manager user-first enforcementPR#2087: The sliding window could produce a trimmed conversation starting with an assistant message, causing ValidationException on providers that require user-first ordering (including Bedrock Nova). The trim-point validation now ensures the first remaining message always has role == "user". Also fixed a short-circuit logic bug in the toolUse guard that let orphaned tool-use blocks slip through at window boundaries.

    • MCP _meta forwardingPR#1918, PR#2081: Custom metadata per the MCP spec was silently dropped because MCPClient never forwarded the _meta field to ClientSession.call_tool(). Additionally, the OTEL instrumentation used model_dump() instead of model_dump(by_alias=True), serializing the field as "meta" instead of "_meta" and corrupting the payload. Both the direct call_tool and task-augmented execution paths now correctly forward meta.

    • Tool exception propagation to OpenTelemetry spansPR#2046: When a tool raised an exception, the original exception was dropped before reaching end_tool_call_span, causing all tool spans to get StatusCode.OK even on errors. Tool errors now correctly propagate with StatusCode.ERROR, preserving the original exception type and traceback for observability backends like Langfuse.

    • Anthropic premature stream terminationPR#2047: The Anthropic provider crashed with AttributeError when the stream terminated before the final message_stop event, because it accessed event.message.usage on event types that lack a .message attribute. Now uses the Anthropic SDK’s stream.get_final_message() to read accumulated usage from all received events, gracefully handling premature termination and empty streams.

    • Anthropic Pydantic deprecation warningsPR#2044: Fixed message_stop event handling to avoid Pydantic deprecation warnings.

    ✦ Features 1

    ⊘ Fixes 8

    ⋯ 1 more change (chores, docs, tests)
  11. Features

    Mid-Execution Cancellation — PR#781

    The SDK now supports cooperative cancellation of running agent invocations. Callers can stop an agent mid-execution via agent.cancel(), AbortSignal.timeout(), or any external AbortSignal (e.g., from an HTTP framework). Cancellation is checked between model stream events and before each tool execution, and running tools are never forcibly interrupted — they complete unless the tool itself checks the cancellation signal.

    import { Agent } from '@strands-agents/sdk';
    const agent = new Agent({ model, tools });
    // Cancel after 5 seconds
    setTimeout(() => agent.cancel(), 5000);
    const result = await agent.invoke('Do something long');
    console.log(result.stopReason); // 'cancelled'
    // Or use AbortSignal.timeout for declarative timeouts
    const result = await agent.invoke('Hello', {
    cancellationSignal: AbortSignal.timeout(5000),
    });
    // Framework-driven cancellation (e.g., Express)
    app.post('/chat', async (req, res) => {
    const result = await agent.invoke(req.body.message, {
    cancellationSignal: req.signal,
    });
    res.json(result);
    });

    Tools can participate in cooperative cancellation by checking context.agent.cancellationSignal:

    callback: async ({ url }, context) => {
    // Signal forwarding — fetch aborts if agent is cancelled
    const res = await fetch(url, { signal: context.agent.cancellationSignal });
    return res.text();
    };

    ⚠️ Type change: StopReason union now includes 'cancelled'.

    Agent-as-Tool — PR#768

    Agents can now be used as tools for other agents via agent.asTool() or by passing agents directly in the tools array (auto-wrapped). This enables hierarchical multi-agent architectures where a parent agent delegates subtasks to specialized child agents. The preserveContext option controls whether the sub-agent retains conversation history across invocations.

    const researcher = new Agent({
    name: 'researcher',
    description: 'Finds information on a topic',
    tools: [searchTool],
    printer: false,
    });
    // Pass agents directly in tools array (auto-wrapped)
    const writer = new Agent({
    tools: [researcher],
    });
    await writer.invoke('Write an article about quantum computing');
    // Or wrap explicitly with options
    const tool = researcher.asTool({ preserveContext: true });

    Multi-Agent Session Persistence — PR#764

    SessionManager now implements MultiAgentPlugin, enabling Graph and Swarm orchestrators to automatically save and restore execution state. After each orchestrator invocation, the multi-agent state (node statuses, results, steps, app state) is persisted. On the next invocation, the snapshot is restored before the first node executes.

    import { SessionManager, FileStorage, Graph } from '@strands-agents/sdk';
    const session = new SessionManager({
    sessionId: 'my-session',
    storage: { snapshot: new FileStorage() },
    });
    const graph = new Graph({
    id: 'my-graph',
    nodes: [researcher, writer, editor],
    edges: [['researcher', 'writer'], ['writer', 'editor']],
    plugins: [session],
    });
    // First invocation — snapshot saved automatically
    await graph.invoke('Write about AI');
    // Second invocation — state restored from snapshot
    await graph.invoke('Continue');

    Scope isolation between agent and multi-agent snapshots is maintained. A shared SessionManager across multiple orchestrators restores each one independently.

    Multi-Agent Snapshot Primitives — PR#756

    Adds internal snapshot take/load support for multi-agent orchestrators (Graph and Swarm). takeSnapshot() captures orchestrator ID and serialized execution state; loadSnapshot() validates scope, schema version, and orchestrator ID before restoring state. Also adds scope validation to agent loadSnapshot — passing a multi-agent snapshot to the agent loader now throws instead of silently no-oping.

    Bug Fixes

    • Invocation lock leak on stream breakPR#796: When a consumer broke out of for-await-of on agent.stream(), the runtime called .return() on the generator. The finally block tried to drain remaining events via yield, but with no consumer to resume the generator, it suspended permanently. The lock disposable never ran, leaving _isInvoking stuck at true and causing ConcurrentInvocationError on any subsequent call. Drain events are now only yielded when the consumer may still be iterating.

    • Bedrock thinking + forced tool_choice conflictPR#798: When using structuredOutputSchema with extended thinking enabled, the agent hit a Bedrock API error: “Thinking may not be enabled when tool_choice forces tool use.” The SDK now strips the thinking key from additionalRequestFields when toolChoice forces tool use (any or tool variants), preserving thinking for auto and unset tool choice.

    • Bedrock context window overflow detectionPR#782: Synced BEDROCK_CONTEXT_WINDOW_OVERFLOW_MESSAGES with the Python SDK to include the 'prompt is too long' error pattern, ensuring consistent overflow detection across both SDKs.

    ✦ Features 5

    ⊘ Fixes 5

  12. Major Features

    Ground Truth Assertion Support for Goal Success Rate Evaluator — PR#180

    The GoalSuccessRateEvaluator now supports a second evaluation mode: assertion-based evaluation. When expected_assertion is provided on the evaluation case, the judge LLM evaluates whether the agent’s behavior satisfies explicit success assertions rather than inferring goals from the conversation. This enables precise, reproducible evaluation with human-authored success criteria.

    from strands_evals import Case
    from strands_evals.evaluators import GoalSuccessRateEvaluator
    # Basic mode (existing) — judge infers goals from conversation
    case_basic = Case(
    session_id="session-1",
    goal="Help user book a flight",
    )
    # Assertion mode (new) — judge evaluates against explicit criteria
    case_assertion = Case(
    session_id="session-2",
    goal="Help user book a flight",
    expected_assertion="Agent confirmed departure city, destination, and date before searching for flights",
    )
    evaluator = GoalSuccessRateEvaluator()

    Basic mode uses a Yes/No scoring rubric (Yes=1.0, No=0.0). Assertion mode uses SUCCESS/FAILURE scoring (SUCCESS=1.0, FAILURE=0.0).

    Provider as Task Callable — PR#183

    TraceProvider now exposes an as_task() method that returns a task callable, eliminating the need to write wrapper functions when running evaluations against traced sessions.

    from strands_evals.providers import TraceProvider
    provider = TraceProvider(...)
    # Before: manual wrapper
    task = lambda case: provider.get_evaluation_data(case.session_id)
    # After: built-in convenience
    task = provider.as_task()

    ✦ Features 1

    ⋯ 1 more change (chores, docs, tests)
  13. ✦ Features 8

    ⊘ Fixes 20

    Show 12 more fixes
    ⋯ 2 more changes (chores, docs, tests)
  14. ✦ Features 13

    Show 5 more features

    ⊘ Fixes 10

    Show 2 more fixes
    ⋯ 10 more changes (chores, docs, tests)
  15. Major Features

    A2AAgent: First-Class Client for Remote A2A Agents - PR#1441

    The new A2AAgent class makes it simple to connect to and invoke remote agents that implement the Agent-to-Agent (A2A) protocol. A2AAgent implements the AgentBase protocol, so it can be called synchronously, asynchronously, or used in streaming mode just like a local Agent. It automatically discovers the remote agent’s card to populate its name and description, and manages HTTP client lifecycle for you.

    from strands.agent.a2a_agent import A2AAgent
    # Connect to a remote A2A agent
    a2a_agent = A2AAgent(endpoint="http://localhost:9000")
    # Invoke it like any other agent
    result = a2a_agent("Show me 10 ^ 6")
    print(result.message)
    # Or stream events asynchronously
    async for event in a2a_agent.stream_async("Summarize this report"):
    if event.get("type") == "a2a_stream":
    print(f"A2A event: {event['event']}")
    elif "result" in event:
    print(f"Final: {event['result'].message}")

    A2AAgent Support in Graph Workflows - PR#1615

    Graph nodes now accept any AgentBase implementation as an executor, not just the concrete Agent class. This means A2AAgent instances and other custom AgentBase implementations can participate in graph-based multi-agent workflows alongside local agents. The Agent class also now explicitly extends AgentBase for compile-time protocol verification.

    from strands import Agent
    from strands.agent.a2a_agent import A2AAgent
    from strands.multiagent.graph import GraphBuilder
    local_agent = Agent(name="summarizer", system_prompt="Summarize input concisely.")
    remote_agent = A2AAgent(endpoint="http://remote-agent:9000")
    builder = GraphBuilder()
    builder.add_node(remote_agent, "research")
    builder.add_node(local_agent, "summarize")
    builder.add_edge("research", "summarize")
    graph = builder.build()
    result = graph("Analyze recent AI trends")

    Interrupt Support for MultiAgent Graph Nodes - PR#1606

    Interrupts now propagate correctly through nested multi-agent graph nodes. When an agent inside a nested Graph, Swarm, or any custom MultiAgentBase node raises an interrupt, the outer graph pauses execution and surfaces the interrupt to the caller. After the caller provides a response, the outer graph resumes execution from where it left off. This builds on the interrupt support for single-agent graph nodes added in v1.24.0.

    from strands import Agent, tool
    from strands.interrupt import Interrupt
    from strands.multiagent import GraphBuilder, Status
    from strands.types.tools import ToolContext
    @tool(context=True)
    def approval_tool(tool_context: ToolContext) -> str:
    return tool_context.interrupt("approval", reason="Needs human approval")
    agent = Agent(name="reviewer", tools=[approval_tool])
    inner_graph = GraphBuilder()
    inner_graph.add_node(agent, "reviewer")
    outer_graph = GraphBuilder()
    outer_graph.add_node(inner_graph.build(), "review_pipeline")
    graph = outer_graph.build()
    result = graph("Review this document")
    while result.status == Status.INTERRUPTED:
    responses = [
    {"interruptResponse": {"interruptId": i.id, "response": "Approved"}}
    for i in result.interrupts
    ]
    result = graph(responses)

    S3 Location Support for Documents, Images, and Videos - PR#1572

    Media content types now support S3 locations as a source, allowing you to reference documents, images, and videos stored in Amazon S3 directly without base64 encoding. The new S3Location type includes a required uri field and an optional bucketOwner field for cross-account access. On Bedrock, S3 locations are passed through to the API natively.

    from strands import Agent
    agent = Agent()
    response = agent([{
    "role": "user",
    "content": [
    {"text": "Summarize this document:"},
    {
    "document": {
    "format": "pdf",
    "name": "report",
    "source": {
    "location": {
    "type": "s3",
    "uri": "s3://my-bucket/documents/report.pdf",
    "bucketOwner": "123456789012" # optional, for cross-account
    }
    }
    }
    }
    ]
    }])

    Configurable Structured Output Prompt - PR#1627

    The prompt message the agent uses to request structured output formatting is now configurable via the structured_output_prompt parameter. Previously, the hardcoded message "You must format the previous response as structured output." could trigger Bedrock Guardrails prompt-attack filters. You can now customize this message at the agent level or per invocation to work around guardrail rules or to better suit your use case.

    from strands import Agent
    from pydantic import BaseModel
    class UserInfo(BaseModel):
    name: str
    age: int
    # Custom prompt avoids triggering Bedrock Guardrails prompt attack filter
    agent = Agent(
    structured_output_model=UserInfo,
    structured_output_prompt="Please use the output tool now."
    )
    # Or override per invocation
    result = agent(
    "Extract user info from: John is 30 years old",
    structured_output_prompt="Format the response using the output tool."
    )

    Major Bug Fixes

    • Nullable Semantics Preserved for Required Union[T, None] Tool Parameters - PR#1584 When a @tool parameter was typed as T | None without a default value, the schema cleaning logic was stripping null from the anyOf array, making the field required with no way to express null. This caused LLMs to fall back to passing "null" as a string. The anyOf simplification is now skipped for fields in the required array, preserving proper nullable semantics.

    • LedgerProvider Now Handles Parallel Tool Calls Correctly - PR#1559 When agents proposed multiple tool calls in a single model response, the ledger provider only updated the last tool in the batch (ledger['tool_calls'][-1]), leaving earlier pending tools without proper status updates. The provider now correctly tracks and updates each individual tool call.

    • Context Overflow Detection for OpenAI-Compatible Bedrock Endpoints - PR#1529 OpenAI-compatible endpoints that wrap Bedrock models (e.g., Databricks Model Serving) return Bedrock-style error messages like "Input is too long for requested model" as APIError instead of BadRequestError with code context_length_exceeded. These errors are now detected and converted to ContextWindowOverflowException, allowing conversation managers like SummarizingConversationManager to trigger properly.

    • retry_strategy=None Now Disables Retries - PR#1630 Passing retry_strategy=None to the Agent constructor previously applied the default retry strategy instead of disabling retries. Now None explicitly disables all SDK retries (equivalent to ModelRetryStrategy(max_attempts=1)), while omitting the parameter entirely still applies the default strategy. Note: This is a minor breaking change in behavior for code that explicitly passed None.

    • A2A Server Agent Card URL Updated on Host/Port Override - PR#1626 When overriding host and port in A2AServer.serve(), the agent card at /.well-known/agent-card.json still returned the original URL from constructor defaults, causing A2A clients to connect to the wrong address. The server now updates the agent card URL to match the overridden host/port, unless an explicit http_url was provided in the constructor.


    Minor Changes

    • Increase pytest timeout to 45 seconds - PR#1586
    • Publish integ test results to CloudWatch - PR#1587
    • Clone main metrics upload script for integ tests - PR#1600
    • Skip S3 location for non-Bedrock model providers - PR#1602
    • Add conditional execution for finalize step - PR#1605
    • Fix various test warnings - PR#1613
    • Fix Bedrock file warnings - PR#1603
    • Increase test timeout - PR#1623
    • Fix OpenAI test - PR#1624
    • Remove broken MCP transport timeout test - PR#1635
    • Bump actions/setup-python from 4 to 6 - PR#1548
    • Bump aws-actions/configure-aws-credentials from 4 to 5 - PR#1547
    • Bump actions/download-artifact from 4 to 7 - PR#1609
    • Bump actions/upload-artifact from 4 to 6 - PR#1608

    ✦ Features 3

    ⊘ Fixes 5

    ⋯ 16 more changes (chores, docs, tests)
  16. Major Features

    Google Gemini Model Provider - PR#426

    A new GeminiModel provider brings native Google Gemini support to the TypeScript SDK. It integrates with the @google/genai SDK and supports streaming text responses with the standard ModelStreamEvent interface, configurable model parameters, and API key authentication via constructor option or the GEMINI_API_KEY environment variable. You can also pass a pre-configured GoogleGenAI client instance. The default model is gemini-2.5-flash. This initial release supports text content streaming, with tool calling planned for a follow-up.

    import { GeminiModel } from 'strands-agents/models/gemini'
    import { Agent } from 'strands-agents'
    // With API key (or set GEMINI_API_KEY env var)
    const model = new GeminiModel({
    apiKey: 'your-api-key',
    modelId: 'gemini-2.5-flash',
    params: { temperature: 0.7, maxOutputTokens: 1024 },
    })
    const agent = new Agent({ model })
    const result = await agent.invoke('What is the capital of France?')
    // Or pass a pre-configured client
    import { GoogleGenAI } from '@google/genai'
    const client = new GoogleGenAI({ apiKey: 'your-api-key' })
    const model2 = new GeminiModel({ client })

    Tool Call Retry via Hooks - PR#493

    Hooks can now request tool execution retries through a new retry property on AfterToolCallEvent, bringing feature parity with the Python SDK. When a hook sets event.retry = true, the agent discards the current result from conversation history and re-executes the tool, emitting a fresh BeforeToolCallEvent on each attempt. Retries work both for error recovery and for success-based re-evaluation when the result doesn’t meet criteria. Breaking change: AfterModelCallEvent.retryModelCall has been renamed to AfterModelCallEvent.retry for consistency.

    import { Agent } from 'strands-agents'
    import { AfterToolCallEvent } from 'strands-agents/hooks/events'
    const agent = new Agent({ model, tools: [myTool] })
    let attempts = 0
    agent.hooks.addCallback(AfterToolCallEvent, (event: AfterToolCallEvent) => {
    attempts++
    if (event.error && attempts < 3) {
    event.retry = true // Tool will be re-executed
    }
    })

    Migration note: Rename afterModelCallEvent.retryModelCall = true to afterModelCallEvent.retry = true.


    Bug Fixes

    • Add @google/genai to devDependencies - PR#502 Fixed CI build failures by adding the @google/genai package to devDependencies, ensuring TypeScript compilation succeeds for the new Gemini model provider.

    ✦ Features 2

    ⊘ Fixes 1

    ⋯ 3 more changes (chores, docs, tests)
  17. Major Features

    Response Relevance Evaluator - PR#112

    The new ResponseRelevanceEvaluator measures how well an agent’s response addresses the user’s question. It uses a 5-level LLM-as-judge scoring system — Not At All (0.0), Not Generally (0.25), Neutral/Mixed (0.5), Generally Yes (0.75), and Completely Yes (1.0) — with a pass threshold at ≥0.5. Like other trace-level evaluators, it requires an actual_trajectory session and supports both sync and async evaluation.

    from strands_evals.evaluators import ResponseRelevanceEvaluator
    evaluator = ResponseRelevanceEvaluator()
    results = evaluator.evaluate(evaluation_data)
    # results[0].score -> 1.0 (for COMPLETELY_YES)
    # results[0].test_pass -> True (score >= 0.5)
    # results[0].reason -> "The response directly answers the question."
    # results[0].label -> ResponseRelevanceScore.COMPLETELY_YES

    Conciseness Evaluator - PR#115

    The new ConcisenessEvaluator assesses whether an agent’s response is appropriately concise. It uses a 3-level scoring system: Perfectly Concise (1.0) for responses that deliver exactly what was asked, Partially Concise (0.5) for minor extra wording, and Not Concise (0.0) for verbose or repetitive content. The pass threshold is ≥0.5. Both evaluators accept an optional custom model and system_prompt for the LLM judge.

    from strands_evals.evaluators import ConcisenessEvaluator
    evaluator = ConcisenessEvaluator()
    results = evaluator.evaluate(evaluation_data)
    # results[0].score -> 0.0 (for NOT_CONCISE)
    # results[0].test_pass -> False (score < 0.5)
    # results[0].label -> ConcisenessScore.NOT_CONCISE

    Automatic Retry with Exponential Backoff for Throttled Evaluations - PR#107

    Experiment.run_evaluations() and run_evaluations_async() now automatically retry on throttling errors using tenacity. Both task execution and evaluator execution are wrapped with exponential backoff (up to 6 attempts, 4s → 240s). Throttling is detected for ModelThrottledException, EventLoopException, and botocore ClientError with codes like ThrottlingException and TooManyRequestsException. Non-throttling errors are raised immediately without retrying. Additionally, one evaluator failing no longer prevents other evaluators from running on the same case.

    from strands_evals import Case, Experiment
    from strands_evals.evaluators import OutputEvaluator, ConcisenessEvaluator
    experiment = Experiment(
    cases=[Case(name="test", input="What is 2+2?", expected_output="4")],
    evaluators=[
    OutputEvaluator(rubric="Is the answer correct?"),
    ConcisenessEvaluator(),
    ],
    )
    # Throttling errors are retried automatically with exponential backoff.
    # If one evaluator fails, the other still runs.
    reports = experiment.run_evaluations(my_agent)

    Bug Fixes

    • Replace Deprecated Structured Output Methods - PR#67 Updated OutputEvaluator to call the agent with the structured_output_model parameter instead of the removed structured_output() and structured_output_async() methods. Without this fix, OutputEvaluator would fail on recent Strands SDK versions.

    ✦ Features 2

    ⊘ Fixes 1

    ⋯ 3 more changes (chores, docs, tests)
  18. ✦ Features 10

    Show 2 more features

    ⊘ Fixes 7

    ⋯ 13 more changes (chores, docs, tests)
  19. Strands Evaluation is a powerful framework for evaluating AI agents and LLM applications. From simple output validation to complex multi-agent interaction analysis, trajectory evaluation, and automated experiment generation, Strands Evaluation provides comprehensive tools to measure and improve your AI systems.

    Feature Overview

    • Multiple Evaluation Types: Output evaluation, trajectory analysis, tool usage assessment, and interaction evaluation
    • LLM-as-a-Judge: Built-in evaluators using language models for sophisticated assessment with structured scoring
    • Trace-based Evaluation: Analyze agent behavior through OpenTelemetry execution traces
    • Automated Experiment Generation: Generate comprehensive test suites from context descriptions
    • Custom Evaluators: Extensible framework for domain-specific evaluation logic
    • Experiment Management: Save, load, and version your evaluation experiments with JSON serialization
    • Built-in Scoring Tools: Helper functions for exact, in-order, and any-order trajectory matching
    • Simulators: Enable multi-turn evaluation of conversational agents by generating realistic interaction patterns that adapt based on agent responses to create authentic evaluation scenarios
  20. ✦ Features 9

    Show 1 more features

    ⊘ Fixes 5

    ⋯ 18 more changes (chores, docs, tests)
  21. ✦ Features 4

    ⊘ Fixes 4

    ⋯ 39 more changes (chores, docs, tests)