Skip to content

Promptise Foundry Changelog

All notable changes to Promptise Foundry are documented here.


v1.6.0 — 2026-10-21

Python functions are tools: from promptise import tool declares one, with its schema taken from the type hints and the docstring and no langchain_core import. MCPClient reuses its HTTP connections over Streamable HTTP instead of opening one per request, promptise runtime start --detach on Windows no longer loses track of a runtime that has started, a promptise run --tui run ends after its program's last output line, and every page of docs.promptise.com has a title and description that say what it covers.

Added

  • Native tools: @tool -- from promptise import tool makes a Python function a tool for build_agent(extra_tools=[...]), without importing langchain_core:
from promptise import CallerContext, ToolError, build_agent, tool

@tool
async def get_order(order_id: str, caller: CallerContext) -> dict:
    """Look up an order by its ID.

    Args:
        order_id: The order ID, for example "A-1001".
    """
    order = await orders.get(order_id, owner=caller.user_id)
    if order is None:
        raise ToolError(f"No order {order_id}.")
    return order

agent = await build_agent(model="openai:gpt-5-mini", extra_tools=[get_order])

The tool's name is the function's name, its description the docstring without the parameter section, and its parameter schema comes from the type hints (defaults, Optional, Literal, enums, lists, dicts, nested Pydantic models, Field constraints), with each parameter described by Annotated[..., Field(description=...)], Annotated[..., "text"] or the docstring (Google Args:, NumPy Parameters or Sphinx :param:). It is the schema @server.tool() builds for the same function, and for a simple function the one LangChain's @tool builds, so switching the import changes nothing the model sees. Arguments are validated before the function runs; invalid ones reach the model as a tool error that lists what to correct. A CallerContext parameter receives the caller of ainvoke(..., caller=...) and is hidden from the model. Raising ToolError (now also importable as promptise.ToolError) sends the model the error envelope an MCP server sends, and timeout= answers a call that runs longer with a retryable TIMEOUT error. Sync functions run in a worker thread, so they never block the event loop. Results reach the model as text: a string as it is, None as an empty result, anything else (dicts, lists, Pydantic models, dataclasses) as JSON. The tool is a LangChain BaseTool (promptise.function_tools.FunctionTool), so approval policies, guardrails, tracing, events, tool optimization, cross-agent delegation and code-action treat it like any other tool, and it stays callable like the function for unit tests (await get_order("A-1001")). extra_tools also accepts plain functions (made tools as if decorated) and still takes LangChain tools; anything else raises TypeError before a server is connected. See docs/core/tools.md.

Fixed

  • AgentProcess.status()["queue_size"] left out events a worker was holding (since 1.5.0). A worker takes the next event off the queue at once and holds it while the process is starting or suspended, or until a concurrency slot frees up, so a suspended process with two waiting events reported queue_size: 1, and promptise runtime status and the dashboard showed too few. Held events now count as queued until their run starts, and they also keep the process from idling out.
  • A promptise run --tui run could say it had exited before its program's last lines. ChildRunSource gets the child's run events over its IPC channel and its output over the stdout/stderr pipes, and forwarded both as they arrived. The child publishes state: exited and run.exit just before it exits, so on a busy machine the stream said the run had ended before its last printed lines, and every run had two exited states. It now holds the child's run.exit, reads both pipes to the end once the process has ended, waits for the channel to deliver run.exit or close (each wait at most 2 s, as before), then publishes run.exit and a single exited. A restart ends the old run the same way, so the old run's last lines no longer show up after the new run started.
  • MCPClient opened a new TCP connection for every Streamable HTTP request (with mcp 1.x and 2.x). The MCP SDK closes the event stream that answers a request as soon as it has the JSON-RPC response, before the end of the stream has been read, and an HTTP/1.1 connection closed in the middle of a response can't be reused. Each closed connection kept a local port in TIME_WAIT, so an agent calling a server many times ran out of ports (Can't assign requested address, seen on macOS). The client now reads the rest of that stream when it is closed (at most 1 s and 64 KiB; the server ends it right after the response), so the connection goes back to the pool: in a local test, 50 calls in a row opened 3 connections (the handshake, the session's event stream and one for the calls) instead of 54. Per-caller sessions keep connections of their own, so a connection never carries two callers' tokens. The SSE transport already reused its connections.
  • promptise runtime start --detach on Windows could report "still starting after 60s" for a runtime that had started. While it waited, the command read the runtime's state file every 0.2 s. On Windows that read fails while the runtime replaces the file to record that it is ready, and the command took the unreadable file for a stale one and deleted it: the runtime kept running without a state file, so status and stop could not find it. status, stop and the other commands could delete it the same way, and a second start with the same name could take the name over in that moment. A state file that can't be read is now read again (for up to 1 s on Windows) and never removed; only a file whose process is gone, or that is not a valid state file, is stale.

Changed

  • A run stopped by a limit with more than one tool call pending reports them as "calls to a and b whose results the model never saw" (it said "a call to a, b whose result").
  • MCP server: more parameter descriptions. @server.tool() (and @server.prompt()) also read NumPy-style Parameters sections, and @server.tool() reads a plain string in Annotated[...] (Annotated[str, "The order ID."]) as the parameter's description, as @tool does. A Field description still wins over both, and the Annotated string over the docstring.
  • Docs: the Tools page starts with declaring tools with @tool, and the Building Agents, RAG, code-action and projects pages, three blog posts, the promptise init --project template and the examples use from promptise import tool.
  • Docs: the MCP client page describes how a client uses its HTTP connections, and the runtime CLI page says that a state file which can't be read is left alone.
  • Docs: every page on docs.promptise.com has a title that names what it covers ("Agent Memory: Vector Search, Chroma and Mem0" rather than "Memory"), followed by "| Promptise Docs", and a description; generic page headings are more specific.
  • Docs: the InjectionDetector page no longer says it detects jailbreaks. Its default model's card says it handles English only and does not detect jailbreak attacks, and the model's project is archived; the page says so and how to pass another classifier.
  • Development: ruff may be up to 0.17 and pymdown-extensions up to 12.2 in the dev/docs extras.

v1.5.2 — 2026-10-21

MCPClient no longer reports a server's own error as a lost connection (a regression in 1.5.1), a stdio server answers the requests still running when its client closes stdin, get_node_status() reports an unreachable node as NodeUnreachableError, and an event or message trigger dead-letters every event its full buffer drops, also before its listener runs.

Fixed

  • MCPClient reported a server's JSON-RPC error with code -32000 as a lost connection (new in 1.5.1, with mcp 1.x and 2.x). The client took every -32000 error for the SDK's "Connection closed", but -32000 is also the generic server-error code, and a Promptise server answers a ToolError raised in a resource or prompt handler with it. Reading a missing resource raised Connection to ... was lost (MCPError: There is no handbook page 'holidays'.) although the connection was fine and later calls worked. Only the SDK's own error (code -32000, message Connection closed, no data) and a closed stream count as a lost connection now; a server's error is reported as such again, as in 1.5.0: Failed to read resource 'docs://pages/holidays': There is no handbook page 'holidays'.
  • A stdio server exited without answering a request when the client closed stdin right after sending it (a shell pipe, a one-shot script; with mcp 1.x the tools/call answer was almost always missing, with 2.x sometimes). Both SDKs cancel the requests still running when the read side ends. The server now keeps the session open after stdin's EOF until those requests are answered and written to stdout, waiting at most shutdown_timeout seconds (30 by default), and stops waiting at once for a request that waits on the client (an elicitation).
  • RuntimeCoordinator.get_node_status() raised a plain RuntimeError for an unreachable node, so failover code catching NodeUnreachableError missed it. It raises NodeUnreachableError (a RuntimeError subclass, with the node_id), as documented; except RuntimeError still catches it, and the message still starts with Status request failed:. start_process_on_node(), stop_process_on_node() and inject_event_on_node() already raised it.
  • Events an event or message trigger dropped before its listener ran were counted but not dead-lettered. The overflow handler was only set when the trigger's listener started, so a burst (or a bus that replays a backlog on subscribe) that filled the 100-event buffer during start(), add_trigger() or an agent's add_trigger was only counted in dropped_events. The handler is now set when the trigger is created, so every dropped event is dead-lettered with reason "trigger buffer full".

Changed

  • Docs: the event, message and webhook trigger page no longer says a full event or message buffer drops events "with a warning"; it describes the 1.5.1 behavior (counted in dropped_events and dead-lettered). The triggers overview lists the trigger buffer full dead-letter reason and the set_overflow_handler() member of custom triggers, and the coordinator page says which errors get_node_status() raises. The MCP deployment and building-servers pages describe what a stdio server does when stdin closes, and the client page says a server's error is not a lost connection.

v1.5.1 — 2026-10-21

Promptise now works with the MCP Python SDK 2.x as well as 1.x, the SPIFFE extra installs, sandboxed code no longer runs as root, PII patterns stop redacting parts of numbers, and a set of runtime fixes, among them a stop() race that could run a finished run again.

Upgrade notes

  • The MCP SDK 2.x is supported, and a new install gets it. The requirement is mcp>=1.9,<3 (was <2), so Promptise installs next to packages that need the 2.x SDK, and 2.x clients reach a Promptise server with the 2026-07-28 protocol revision. Promptise's APIs are unchanged. Code of your own that reads MCP SDK objects (the Tool, CallToolResult... that MCPClient and TestClient return) follows the SDK: 2.x has snake_case attributes (tool.input_schema, result.is_error). Pin "mcp>=1.9,<2" to keep the 1.x SDK. pydantic-settings is now a direct dependency (the 1.x SDK installed it, 2.x doesn't).
  • On a 2026-07-28 connection the server can't send requests to the client, so elicitation (including ElicitationApprover) and sampling are declined there, and gated calls fail closed. Promptise's MCPClient always uses the handshake, where they work; with the 2.x SDK's own client use Client(..., mode="legacy").
  • Sandboxed code runs as nobody. SandboxConfig.user (also in a .superagent sandbox: section) defaults to "65534:65534"; before, the container ran as the image's default user, root in python:3.11-slim. The workspace (HOME) and /tmp stay writable and pip install still installs into the workspace. Set user="root" for the old behavior, or the image's user name for a custom image.
  • promptise[identity-spiffe] depends on spiffe (py-spiffe), the package that exists on PyPI. 1.5.0's pyspiffe>=1.0 could not be installed, so [identity-spiffe] and [identity-all] were unusable.
  • PII redaction changed for some inputs. A number pattern now matches a whole number or nothing (an order number 2024-118-5521 is no longer redacted as a phone number, a 14-digit value after NPI no longer as an NPI); a spaced IBAN is redacted whole as [IBAN] (it was partly redacted as an Aadhaar number); contextual bank accounts take up to 34 digits.
  • Some trigger IDs change once. A trigger whose ID hashed a function filter_expression (its ID changed on every start anyway), or a custom_config value with a credential-like key or a URL with user info, gets a new stable ID: those values are now masked before hashing, as documented.
  • promptise runtime start has an in-process event bus. event triggers and escalations with an event_type now work between the processes of one CLI runtime (an event trigger could not start before).
  • A run refused by max_runs_per_day is dead-lettered (reason "budget: max_runs_per_day") and its task_completed outcome is "refused"; it reported "success" and was lost.
  • An unset ${VAR} in a manifest raises ManifestEnvVarError, which is both a ManifestError and the EnvVarNotFoundError raised before.
  • An async def filter_expression is rejected when the TriggerConfig is built; it was never awaited, so every event passed.

Fixed

  • mcp 2.x: import promptise failed (McpError is MCPError) and the server and client could not run on it. A compatibility layer (promptise.mcp._compat, promptise.mcp.server._sdk) registers the server's handlers on either SDK's lowlevel server, reads protocol fields in either spelling, hands the SDK httpx2 objects with 2.x, keeps HTTP 401/403/404 statuses that 2.x no longer raises (so MCPConnectionRejectedError and session re-initialisation behave as with 1.x), reports a failed bearer_token_provider as MCPCredentialError, and keys list_changed notifications and SessionState on the connection (2.x builds a new session object per request). The 1.x code paths are unchanged.
  • SPIFFE SDK mode never worked -- it imported pyspiffe.workloadapi... and called WorkloadApiClient(spiffe_socket_path=...) / fetch_jwt_svid(audiences=...). It now uses spiffe.WorkloadApiClient(socket_path=...), fetch_jwt_svid(audience=...) and JwtSvid.token, also for AgentIdentity.auto() with SPIFFE_ENDPOINT_SOCKET set; a missing or invalid socket raises CredentialAcquisitionError.
  • PII: phone_us matched the last ten digits of longer numbers (2024-118-5521 became 2[PHONE]); spaced IBANs were read as Aadhaar / My Number and partly redacted (an IBAN is now detected in groups of four, checked with the mod-97 checksum, and findings inside it are dropped); and exp 12/2025 became [CARD_EXPIRY]25 -- EIN, NPI, BSN, DEA, Medicare, NHS, NY licence, passport, PAN, VIN, SWIFT, DOB, IPv4, MRN, SSN, ITIN, SIN, RRN and NDC patterns had the same boundary problem.
  • stop() during a finished run's task_completed hook dead-lettered the event ("process stopped", "run cancelled"), and redeliver_dead_letters() then ran the finished run again, repeating its tool calls. The event is released before the hooks run; only events whose run did not finish are dead-lettered.
  • redeliver_dead_letters() didn't wake an idle-suspended process, so redelivered events never ran.
  • Trigger IDs with a function filter changed on every start (they hashed repr(), which has a memory address); they hash the function's module and name.
  • Event and message triggers dropped events silently when their 100-event buffer was full. They count them (dropped_events in status()["triggers"]) and the process dead-letters them ("trigger buffer full").
  • promptise runtime start could not use custom trigger types (validate passed, start failed with "Unknown trigger type"). start and validate take --import MODULE to import the module that registers them, and both report an unknown type before anything starts.
  • promptise runtime validate printed a traceback for an unset ${VAR} and, for a directory, stopped at that file; it reports the file and goes on. promptise runtime start printed a traceback too (for a directory, after "All processes stopped"); it now checks every manifest before starting anything.
  • An approval-denied call used up max_irreversible_per_run (counted when the call starts, before the gate asks). A denied call gives its irreversible action back; it still counts as a tool call and against the cost budget.
  • An unreachable cluster node raised aiohttp or timeout errors from remote operations; they raise NodeUnreachableError, a RuntimeError, as documented.
  • The low_confidence mission escalation had no process_name, unlike the budget and health payloads.
  • A lost connection could be reported as Failed to call tool 'x': (an empty reason), depending on whether the request or the session noticed first; it is reported as Connection to ... was lost (...) on both SDKs. This also fixes a flaky test (test_auto_reconnect_off_reports_the_lost_session).
  • Dashboard: the Processes tab hid Name and Model below about 160 columns (it now drops the least important columns first); the Logs tab's log panel pushed the journal panel off the screen; the header's Events counter showed runs; the Events tab's Payload column was always - (a new payload_summary in the task_created hook data fills it).
  • GET /triggers showed the password of a URL in custom_config (scheme://user:pass@host); URL user info is masked.
  • Messages: COMMANDS_NOT_ALLOWED suggested callback hooks, which a JSON deploy can't carry; rewind --plan said "N of M entries after it" with N counting the target; the escalation warning without an event bus now says how to fix it; a refused cluster node was logged with doubled quotes; and every budget refusal logged LangChain's "Error in BudgetGuard.on_tool_start callback" warning (the guard now logs the refusal at INFO).
  • Manifests: unknown top-level, config: and memory: keys get "did you mean" hints like the other sections.

Changed

  • New: SandboxConfig.user; promptise.runtime.InProcessEventBus; promptise.runtime.ManifestEnvVarError; promptise.runtime.distributed.NodeUnreachableError; promptise.approval.on_approval_denied; --import for promptise runtime start and validate.
  • CI: a test-mcp-v1 job runs the MCP tests and mypy with mcp<2; the main matrix resolves to the 2.x SDK.
  • Docs: installation (MCP SDK versions, identity extras), SPIFFE provider, sandbox user, MCP server SDK compatibility, triggers, processes, hooks, budget, coordinator and CLI pages describe the changes above.
  • Docs corrected where the code was right: a run refused by max_runs_per_day does emit budget.exceeded (budget.md); every journal entry has an entry_id, so secret_access and mission_evaluation entries can be rewind targets (journal index, rewind.md); the rewind worked example's undone count matches its plan and the resume comes before the checkpoint; manifests' triggers are fully validated at load and get_secret exists only in open mode (manifests.md); hook env values stay ${VAR} until the hook runs (hooks.md); the dashboard collector fills the Events, Recent Activity and journal panels, and its trigger IDs are the stable ones (dashboard.md).
  • The purge_user docstrings (cache, memory, observability) describe deleting one user's data on request instead of naming a law (docs only).

v1.5.0 — 2026-10-20

The Agent Runtime now does what its documentation says: unwired features (lifecycle hooks, rewind, distributed mode, the promptise runtime stop | status | restart commands) work, and the runtime docs were rewritten against the code.

Upgrade notes

These changes can alter the behavior of existing code or deployments:

  • Control API errors no longer quote exceptions. A failure caused by an exception inside the runtime (deploy, start, stop, restart, suspend, resume, secret rotation, inbox refusals, rewind, unexpected errors) answers with a fixed message and an error_id; the exception and its traceback are logged on promptise.runtime.api under that id. Pydantic validation errors still list each field. Code that parsed the exception text out of message should use the code (and OrchestrationAPIError.error_id to find the log entry).
  • Unknown keys are reported. Manifests reject unknown keys in every section (with the dotted path and the valid keys), and a servers: entry that is neither an HTTP (url) nor a stdio (command) server is an error instead of being skipped. In Python, OpenModeConfig, JournalConfig, ContextConfig, BudgetConfig, HealthConfig, MissionConfig, SecretScopeConfig, EscalationTarget, ToolCostAnnotation and DistributedConfig still ignore unknown keys but emit a FutureWarning naming them; a future release will reject them. A TriggerConfig option that belongs to another trigger type (watch_path on a cron trigger) is now an error.
  • Journal recovery is on by default. With a journal configured, a process's first start() restores the context state, mission, budget counters, conversation and invocation count from the last checkpoint (JournalConfig.recover_on_start=True). A clean stop() writes a checkpoint too. Set recover_on_start=False to always start fresh.
  • Conversation and memory survive stop() / start(). stop() no longer clears the conversation history or closes the memory provider; close() does. Set ContextConfig.conversation_clear_on_stop=True for the old behavior.
  • SecretScopeConfig.revoke_on_stop is honored. With revoke_on_stop=False, secrets now survive a stop (every stop revoked them before). secrets values are SecretStr; ${VAR} references are resolved when the process starts, not when the config is built, and literal values are masked in repr() and JSON dumps.
  • Health on_anomaly="escalate" always suspends the process, and sends the notification only when escalation is set (without a target it only logged before).
  • Inbox messages are scanned and consumed. InboxConfig.scan_with_guardrails=True (the default) is now applied: flagged messages are refused (422 MESSAGE_REJECTED over the API). Directives and context messages are removed after the run that saw them instead of repeating in every run until their TTL; answered questions are removed.
  • Control API error codes. Deploying a name that exists is 409 PROCESS_EXISTS (was 409 DEPLOY_FAILED); DEPLOY_FAILED is now 422 for a valid config that could not be created. Rotating an unknown secret is 404 SECRET_NOT_FOUND (was 404 PROCESS_NOT_FOUND). An oversized request body is 413 PAYLOAD_TOO_LARGE, an unexpected error 500 INTERNAL_ERROR. See the error-code table in docs/runtime/api.md.
  • API deploys can't run commands by default. A deploy whose config contains stdio (command) MCP servers, command hooks, or open mode with open_mode.allow_tool_creation (the open-mode default) is refused with 403 COMMANDS_NOT_ALLOWED unless the API is created with OrchestrationAPI(allow_command_deploys=True) (promptise runtime start --allow-command-deploys). Deploy open-mode agents with "open_mode": {"allow_tool_creation": false}.
  • API deploys can't use ${VAR} references by default. A deploy, POST .../triggers or PATCH of budget / health / mission whose body contains a ${VAR} reference (outside instructions) answers 403 ENV_REFERENCES_NOT_ALLOWED. Allow specific variables with OrchestrationAPI(allowed_env_vars={...}) (--allow-env-var NAME, repeatable) or all of them with allow_env_references=True (--allow-env-references). An API-deployed process's secrets resolve only those variables.
  • API-chosen paths stay in a data directory. journal.path (file journal), context.memory_persist_directory and file-watch watch_path values in deploys and POST .../triggers must lie inside OrchestrationAPI(data_dir=...) (default ./.promptise); others, and paths with .. or ~, answer 403 PATH_NOT_ALLOWED. The deployed agent's own add_trigger watch paths are held to it too.
  • Agent-connected MCP servers are checked and get no credentials. With an empty open_mode.allowed_mcp_urls, connect_mcp_server refuses hosts that resolve to loopback, private, link-local or other internal addresses (list such URLs in allowed_mcp_urls to allow them), and a process deployed through the API may connect no server at all. Agent-connected servers no longer receive the process identity's credential or a caller's token; list URLs that should in the new open_mode.identity_mcp_urls. An open-mode process that may connect any server logs a warning at start.
  • Agent-added and spawned webhooks listen on loopback. A webhook trigger an open-mode agent adds, or passes to spawn_process, with a non-loopback webhook_host is refused unless the new open_mode.allow_public_webhooks is on (and then it needs an hmac_secret).
  • Unsigned loopback webhooks refuse browser requests. A webhook trigger without hmac_secret on a loopback address answers 421 for a non-loopback Host header and 403 for a cross-site Origin, like the control API. Local clients that send neither are unaffected.
  • Spawned processes inherit the parent's hooks. The parent's hooks manager is dispatched for every process it spawns (after the child's own hooks), so its permission_request and other hooks apply to children.
  • ContextConfig(env_prefix="") is refused (an empty prefix exposed the whole environment).
  • promptise runtime start removes PROMPTISE_RUNTIME_TOKEN from its environment after reading it, so processes, hooks and stdio MCP servers it starts no longer inherit the control API token.
  • POST .../start and .../restart of a process whose start raised a KeyError (such as a missing secret) answer 500 START_FAILED / RESTART_FAILED instead of 404 PROCESS_NOT_FOUND.
  • Trigger IDs are stable. Config triggers get IDs of the form <type>-<digest> instead of position-based static_<n>. The old IDs are still accepted by the API trigger routes, with a deprecation warning in the response.
  • DEFAULT_PRODUCTION_CONFIG no longer sets distributed.enabled=True. The flag did nothing before; it now starts a network transport, so the preset leaves it off and is identical to DEFAULT_DEVELOPMENT_CONFIG. DistributedConfig(discovery_method="multicast") is rejected (it was never implemented).
  • ShellHook gets a minimal environment (no inherited credentials) unless inherit_env=True or env_passthrough is set, and runs without a shell.
  • The budget daily reset honors daily_reset_hour_utc; the counters no longer also reset at midnight UTC.
  • StateError subclasses ProcessStateError (it is still a ValueError), so all three except clauses catch an invalid transition.
  • Mem0Provider.close() keeps stored memories. It called Mem0's Memory.reset(), which deleted every memory.

Fixed

  • AgentProcess.stop() could hang on Python 3.10 and 3.11 when a hook was running -- hook timeouts used asyncio.wait_for, which before Python 3.12 can swallow the caller's cancellation, so a worker cancelled by stop() kept running. Hook timeouts no longer use it, and stop() cancels workers, listeners and the heartbeat until they have really ended.
  • AgentProcess.stop() could hang forever after an open-mode reload during a run -- the MCP client a hot reload replaces is closed once the run that asked for the reload ends, by the worker that ran it. When stop() (or AgentRuntime.stop_all()) cancelled that worker during the close, MCPMultiClient swallowed the CancelledError, the worker went back to waiting for events, and stop() waited for it forever. MCPMultiClient.__aexit__ now still closes every client when it is cancelled, then re-raises the cancellation.
  • ClusterNode.stop() swallowed its own cancellation -- it awaited its background tasks under suppress(CancelledError), so cancelling a stop that was waiting for them was ignored. It now waits with gather(..., return_exceptions=True); the foreground promptise runtime start shutdown waits for its signal waiter the same way.
  • PostgresConversationStore failed on first use -- the sessions table DDL had an unescaped DEFAULT '{}', so str.format() raised IndexError while creating the tables and every Postgres-backed conversation store failed on its first call. The default is now escaped like the messages table's.
  • retry(prompt, max_retries=-1) raised TypeError: exceptions must derive from BaseException -- it ran no attempt and then raised None. A negative max_retries now raises ValueError when the retry is built, and _Fallback refuses an empty prompt list the same way. Retries and fallbacks re-raise the last error as before.
  • Restarting, starting or removing a FAILED process left its old tasks running -- the old trigger, listener and heartbeat kept going, so events fired twice and dead-lettering continued after removal (AgentRuntime.restart_process(), start_all(), remove_process() and POST /restart). They are now torn down first.
  • stop() from CREATED raised StateError and from FAILED never reached STOPPED -- both now stop cleanly and emit process.stopped.
  • Queued trigger events vanished on stop() -- they are dead-lettered ("process stopped") instead.
  • An idle-suspended process never woke up -- with idle_timeout, a cron or webhook agent stayed suspended forever while ticks piled up. The next trigger event now resumes it (process.resumed with reason="trigger").
  • resume() didn't reset the idle clock, so the process was suspended again at the next heartbeat; and events queued while suspended were reordered. Both fixed.
  • process.failed had an empty error after repeated failures -- it carries the real error now.
  • Webhook bodies over 1 MiB got 500 -- they get 413 (checked against Content-Length and while reading chunked bodies). A request that fails with 500 or 503 releases its replay key, so the sender's retry is accepted.
  • File-watch events were dropped when the process was busy -- events now wait (changes to a waiting file are merged); only when watch_max_pending_events different files are waiting is a new one dropped, with an error log and a count in FileWatchTrigger.dropped_events. Polling compares the size as well as the mtime.
  • Health: raising error_window (or other window sizes) on a running monitor silently disabled the check -- windows are resized live, keeping the recorded data. clear_anomalies() (and DELETE /health/anomalies) now resets the cooldowns, and the anomaly history is bounded (HealthMonitor.MAX_ANOMALIES, 1000). on_anomaly="stop" is accepted by HealthConfig (it was unreachable).
  • Mission: success_check could not be used from a process -- MissionConfig.success_check passes it to the tracker. A completed or failed mission no longer becomes active again after a restart (it is restored from the journal). check_mission returns the evaluation history, not only the last one. POST /mission/fail stores the reason and emits mission.failed.
  • Secrets: get_secret results were written to a level="full" journal in plain text (see Security). Rotating an unknown secret returned PROCESS_NOT_FOUND; the client's rotate_secret(ttl=) was ignored by the server.
  • Inbox: eviction dropped the newest lowest-priority message and critical messages could push the inbox past max_messages. It now evicts the oldest lowest-priority message (answered questions first) and refuses a message (InboxFullError, 409 INBOX_FULL) when nothing can be evicted for it. POST /messages checks the process state like send_message() (409 PROCESS_NOT_RUNNING). An /ask call whose question is removed by DELETE /inbox or evicted from a full inbox answers 410 QUESTION_REMOVED (it ended with a closed connection or 500); process.ask() raises InboxQuestionRemoved or InboxFullError.
  • Context: PATCH /context wrote the allowed keys before answering 403 -- it is atomic now: nothing is written when any key is refused. Context updates are journaled (context_update entries) and replayed on recovery.
  • Manifests: context: initial_state: was silently dropped -- every context: key now sets the ContextConfig field of the same name (world: still sets initial_state).
  • API: PATCH of budget, health or mission with enabled: true answered 200 and did nothing -- the subsystem is now built (or torn down) on the running process (AgentProcess.apply_governance_config()). Trigger routes were config-only: an added trigger started only after a restart and a deleted one kept firing. They now start and stop the trigger on a running process.
  • OrchestrationClient.remove_process() and clear_inbox() raised JSONDecodeError after succeeding -- 204 responses are handled.
  • Memory providers broke after a restart (ChromaProvider is closed) -- the provider is created once and closed only by close().
  • Budget counters reset on every restart -- they are checkpointed in the journal and restored, so a restart can't reset the daily limits.
  • promptise runtime stop, status and restart were placeholders, and --detach just exited -- see Added. promptise runtime start also shuts down cleanly on SIGTERM (docker stop used to skip stop_all()).
  • Escalation failures were silent -- a webhook answering with an HTTP error status, a failed delivery, an event_type without an event bus and a target with nothing to deliver to are now logged as warnings (they were debug logs or nothing).

Added

  • Lifecycle hooks are dispatched -- AgentProcess dispatches all 13 HookEvents. Declare them with ProcessConfig.hooks (HookConfig: an async callback, or a command run by ShellHook), in a manifest's hooks: section (commands only), or with process.hooks.on(...). Hooks have priority, once, timeout (5 s for a command, 30 s for a callback), fail_closed and tag; blockable events can be refused with HookBlocked, and a hook can return data (for example a rewritten prompt). ShellHook passes the event as JSON on stdin, runs without a shell, gets a minimal environment plus env / env_passthrough, and is killed beyond max_output_bytes. See docs/runtime/hooks.md.
  • Rewind -- AgentProcess.rewind_plan(entry_id, mode) and rewind(...) restore the conversation, the context state or both to a journal entry (modes both, conversation_only, code_only, summarize), emitting process.rewound. API: GET /api/v1/processes/{name}/rewind/plan and POST /api/v1/processes/{name}/rewind; client: rewind_plan() / rewind(); CLI: promptise runtime rewind <process> [<entry_id>] [--mode ...] [--plan]. See docs/runtime/journal/rewind.md.
  • Distributed mode -- DistributedConfig is read: with enabled=True, AgentRuntime.start_all() / run_forever() start a ClusterNode that runs the management transport and either hosts the coordinator (coordinator=True, with discovery_method="registry" or "static" and static_nodes) or registers with one (coordinator_url), re-registering every heartbeat_interval. New fields: coordinator, node_id, transport_host (default loopback), advertise_url, auth_token (one cluster token, required off loopback unless allow_unauthenticated=True), static_nodes, node_timeout. See docs/runtime/distributed/.
  • promptise runtime control -- start serves the control API on loopback with a random token and records the runtime in a private state directory (~/.promptise/runtime, or $PROMPTISE_RUNTIME_STATE_DIR; 0700/0600, ownership checked). status, stop (graceful, then SIGTERM, --force, --timeout) and restart find it there or use --url / --token ($PROMPTISE_RUNTIME_TOKEN) for a remote API, and -p/--process acts on one process. start --detach runs the runtime in the background with a log file; start has --runtime-name, --api-host and --api-port. logs shows the newest entries with their entry IDs, and --follow works.
  • Process API -- add_trigger() / remove_trigger() on a running process, apply_governance_config(section), fail_mission(reason), record_operator_action(), suspend_reason, recovered, and status() now includes the health summary and file-watch pending_events / dropped_events.
  • New events -- process.suspended (reason: manual, idle, budget or health), process.resumed (reason, suspended_for), process.restart_exhausted and process.rewound.
  • AgentRuntime.run_forever() -- starts every process and serves until SIGTERM / SIGINT (or request_shutdown()), then shuts down cleanly. AgentRuntime(config) now registers the processes in config.processes (only from_config() did).
  • Webhook trigger -- TriggerConfig.webhook_max_body_bytes (default 1 MiB).
  • Security settings -- OrchestrationAPI(allow_env_references=, allowed_env_vars=, data_dir=) and promptise runtime start --allow-env-references / --allow-env-var NAME (passed on to --detach runtimes); OpenModeConfig.identity_mcp_urls and allow_public_webhooks; DistributedConfig.allowed_node_hosts and RuntimeTransport(allowed_node_hosts=); AgentRuntime.add_process(deploy_policy=) and SecretScope(source=, env_references=); SecretResolutionError (a KeyError). New API error codes ENV_REFERENCES_NOT_ALLOWED and PATH_NOT_ALLOWED (both 403).
  • Budget -- BudgetConfig.warning_threshold (default 0.8): the fraction of a limit at which budget.warning is emitted (it was fixed at 80%).
  • File-watch trigger -- watch_backend (auto, watchdog or polling), watch_recursive (default True), watch_poll_interval (default 1 s) and watch_max_pending_events (default 10,000).
  • Health -- HealthMonitor.apply_config(), clear_anomalies() returning the number cleared, and health_status() (public, JSON-safe).
  • Inbox -- refusal exceptions InvalidInboxMessage, InboxRateLimitError, InboxFullError, InboxMessageRejected and InboxQuestionRemoved (all ValueError subclasses), MessageInbox(scanner=...), remove_consumed(), prompt_questions(), parse_answers() and scan_inbox_message().
  • Mission -- MissionTracker.fail(reason, source=), restore(), final completed / failed states and an evaluation history.
  • Secrets -- SecretScope.rotate(name, value, ttl=), list_metadata() (name, active, source, TTL and expiry; never the value), sanitize(), and SecretNotFoundError (a KeyError).
  • Context -- ContextConfig.state_tool gives the agent an update_state(key, value) tool (off by default; limited to writable_keys when set) and ContextConfig.conversation_clear_on_stop.
  • JournalConfig.recover_on_start (default True) and RedactingJournal.
  • Control API -- OrchestrationAPI(on_shutdown=) adds POST /api/v1/runtime/shutdown; OrchestrationClient.shutdown(); OrchestrationAPIError (a subclass of httpx.HTTPStatusError with the API's error code), exported from promptise.runtime; ERROR_CODES lists every code; config updates answer with live and effective_from (for example "next_invocation", "next_budget_check", "restart" or "redeploy") so a change that only applies later says so.

Changed

  • ProcessStateError takes an optional keyword message; StateError passes its message through it instead of setting the inherited attributes itself and skipping ProcessStateError.__init__. The attributes and messages are unchanged.
  • The core approval types live in a private module -- ApprovalRequest, ApprovalDecision, ApprovalHandler, ApprovalRule and ClassifierDecisionTrace are defined in promptise._approval_types, so promptise.approval and promptise.approval_classifier no longer import each other. Import them from the same places as before (promptise, promptise.approval, promptise.approval_classifier); they are the same classes. typing.get_type_hints(ApprovalDecision) now resolves trace (it raised NameError).
  • Silent except blocks -- the ones that hide a real failure now log it: a failing cache-purge notifier, strategy.parse or context-block render in a node, an on_fallback hook, a mission journal write (all DEBUG), and a budget escalation that could not suspend the process (WARNING). The deliberately silent ones say why in a comment. Small code-quality fixes from the same CodeQL pass: a constant conditional in PromptiseSecurityScanner's composable (detectors=) setup, @node passing its docstring description to BaseNode instead of overwriting it, an unused logger.
  • Open-mode meta-tools go through public process methods. A spawned process inherits the parent's budget, health, approval policy, guardrails, trigger delivery, journal level, open-mode limits, hooks and deploy policy, gets none of its secrets, MCP servers or identity, and cannot spawn processes itself.
  • The trigger payload scan reads exactly the payload text the prompt shows (the rendered JSON, cut at max_payload_chars), in overlapping windows, instead of the payload's individual strings.
  • TriggerConfig validation names the field and the fix: watch_backend="watchdog" without the package installed is an error at construction, and InboxConfig rejects a default_ttl longer than max_ttl. EscalationTarget.webhook_url must be an http:// or https:// URL.
  • Every mutating API request is logged to the promptise.runtime.api logger (method, route, status, client; never the body) and, when it succeeds and the process has a journal, recorded as an api_request journal entry.
  • HTTP MCP server credential headers (Authorization, x-api-key, names containing token or secret, ...) are masked in repr() and JSON dumps of server specs.
  • The file journal replaces its checkpoint file atomically.

Security

  • MCP server: session-binding fingerprints are keyed -- the principal that binds an HTTP/SSE session to the credential that opened it was a bare SHA-256 of the bearer token or API key, so anyone who saw a fingerprint (a log line, a debug dump) could test guesses of a short API key offline. It is now an HMAC-SHA256 under a random per-process key: still stable for the life of the sessions it guards, useless outside the process. Session behaviour is unchanged. (CodeQL py/weak-sensitive-data-hashing)
  • Audit log files are created owner-only -- AuditMiddleware(log_path=...) created the file 0o644, so every local user could read which client called which tool (and the arguments with include_args=True). A new file is now created 0o600. An existing file keeps its permissions; appending and resuming the chain are unchanged, and on Windows the call behaves as before. To tighten a log created by an earlier version, chmod 600 it. (CodeQL py/overly-permissive-file)
  • Orchestration API: exception text no longer reaches HTTP clients -- see Upgrade notes. (CodeQL py/stack-trace-exposure)
  • Webhook trigger: request values are escaped before they are logged -- the "rejected request from ..." warnings logged request.remote as is; behind a forwarded-header middleware that is whatever the client sent, so a CR/LF in it could forge log entries. Logged values now have line breaks and other control characters escaped and are capped at 200 characters (private helper promptise._logsafe.log_safe). (CodeQL py/log-injection)
  • Runtime log lines escape client-supplied values -- the control API (refused requests, the audit line, trigger IDs, PATCH sections), AgentRuntime (process names) and the distributed transport, coordinator and registry (node IDs and URLs) logged request values as is; they now go through log_safe like the webhook trigger's. A failed secret rotation no longer logs the secret's name: the warning carries the process, the exception type and the error_id the client gets (the audit line still records the route). (CodeQL py/log-injection, py/clear-text-logging-sensitive-data)
  • ${VAR} references are found in linear time -- ENV_VAR_PATTERN backtracks quadratically on a string full of unterminated references (${A:-${a:-${a:-...), and the control API ran it over every request body to refuse environment references, so one request could stall the API's event loop. promptise.env_resolver.iter_env_references() (and has_env_reference()) find the same references in one pass; the resolver, validate_all_env_vars_available, manifests, secret scopes and the API use them. ENV_VAR_PATTERN is unchanged and still exported. (CodeQL py/polynomial-redos)
  • Secret values never reach the journal in plain text: when secret scoping is on, the journal is wrapped in RedactingJournal, which replaces current and rotated values (the last 10 per secret) with [REDACTED]; get_secret results are no longer journaled at level="full". The conversation history kept between runs and the data passed to lifecycle hooks are redacted the same way.
  • Inbox messages are scanned for prompt injection before they reach the prompt (scan_with_guardrails, see Upgrade notes).
  • API deploys can't start local programs (stdio MCP servers, command hooks) unless allow_command_deploys=True. Open mode with allow_tool_creation now counts as such a deploy: create_tool runs model-written Python in the runtime's process, and its restricted builtins are not an isolation boundary. The docs and docstrings now say so plainly and point to the Docker sandbox for untrusted code.
  • Hardened control-API deploys against reading the host's environment: ${VAR} references in API requests are refused unless the operator allows them, an API-deployed process's secret scope resolves only allowed variables, and resolution errors in API answers no longer reveal whether a variable exists.
  • Answers to inbox questions are redacted: secret values in an /ask answer, process.ask() result or GET /inbox listing are [REDACTED], as in the journal.
  • Agent-connected MCP servers are held to the same address checks as escalation webhooks when no allowlist is set, an API-deployed process needs an explicit allowlist, and the process identity's credential and caller tokens are only sent to servers the operator configured or listed.
  • The trigger payload scan now covers the whole payload text placed in the prompt; text near the end of the max_payload_chars budget could reach the prompt unscanned.
  • API-chosen journal, memory and watch paths are confined to data_dir; env_prefix can't be empty; GET /triggers masks credential-like custom_config values; unsigned loopback webhooks refuse cross-site browser requests; agent-added and spawned webhooks stay on loopback unless allowed; spawned processes inherit their parent's hooks.
  • Distributed transport: a bearer header with non-ASCII bytes is a 401 (it was a 500), and a registry coordinator can limit the hosts nodes register with (allowed_node_hosts); the docs describe why registered URLs need that limit.
  • ShellHook no longer passes the host environment to hook programs and never runs the event payload through a shell.
  • The CLI's state directory holds bearer tokens and is created private (0700 / 0600); directories or files owned by another user or readable by others are refused.
  • DistributedConfig requires a cluster token for a non-loopback transport or a coordinator on another host.

Deprecated

  • Position-based trigger IDs (static_<n>) in the API trigger routes; use the stable IDs from GET /triggers.
  • Unknown keys in the section config models (FutureWarning; they will be rejected).
  • The manifest entrypoint key, which the runtime never read (FutureWarning). Declare hooks in hooks: instead.
  • MissionConfig.on_complete remains deprecated and ignored (FutureWarning); use auto_complete.

v1.4.3 — 2026-10-18

Fixed

  • promptise run --tui no longer shows a NoMatches traceback when it is closed at the moment its once-a-second status refresh runs.

v1.4.2 — 2026-10-18

Fixed

  • promptise run no longer refuses a promptise.toml owned by root when it runs as another user, the usual case in a container image where COPY leaves files owned by root. Manifests owned by another non-root user, or writable by anyone, are still refused.

v1.4.1 — 2026-10-18

Fixed

  • OpenAI and Azure OpenAI models now report token usage when they stream. With recent langchain-openai releases, models built from a provider id ("openai:gpt-5-mini") left stream_usage unset, so every streamed call (astream_with_tools, and the chat, -i and terminal UI modes of promptise run) was recorded with 0 tokens and no cost. Pass stream_usage=False to opt out.

v1.4.0 — 2026-10-17

Added

  • promptise run runs your project, like npm start -- a project is described by promptise.toml (or [tool.promptise] in pyproject.toml), found from the current directory upwards, the way package.json describes a Node project. promptise run runs the manifest's main file as a normal, complete Python program (__name__ == "__main__", sys.argv, exit codes as with python main.py), and with --devtools, --tui, --prod observability or in the agent modes, every agent the program builds is instrumented automatically, with no changes to your code (a plain promptise run adds nothing to the agents). A .py file, file.py:factory or package.module:factory can be named instead; without a manifest, promptise run some_file.py uses the defaults. See docs/core/projects.md.
  • The project manifest -- name, version, description, main, agent (an agent factory, async def create_agent(ctx: RunContext)), python_path, and the tables [scripts], [env], [dev], [prod], [serve] and [pricing]. Unknown keys are errors that name the dotted path, so typos are caught. RunContext (promptise.run) tells a factory the project, root, mode, flags and the --model override.
  • Modes -- script (the program), chat (an interactive chat with the factory's agent: streaming, tool calls inline, /help /stats /tools /reset /exit), once (-i TEXT or -i - for stdin, --json for the answer and stats as JSON) and serve. --mode auto picks one from the target and the main file.
  • Scripts -- [scripts] holds named commands, like npm scripts: promptise run dev. A script runs through the shell from the project root; extra arguments are appended, promptise run options are passed on to scripts that are promptise commands, the running Python's bin directory comes first on PATH, and promptise run --list lists them.
  • Environment -- -e K=V sets a variable for one run; --env-file files and then [env] files (default .env) are loaded in order and never override a variable that is already set; [env] vars sets defaults; every missing [env] required variable is reported at once (exit 2). PROMPTISE_NO_DOTENV=1 turns file loading off.
  • --devtools / -d -- a live trace of every agent on stderr (each LLM turn with model, latency, tokens and estimated cost, each tool call with arguments and result, errors and agent events) and a summary per agent, model and tool when the run ends. Costs come from the manifest's [pricing] table (USD per 1M tokens, globs allowed); there is no built-in price list. [dev] report / --report PATH writes a JSON run report.
  • --tui / -t / -tui -- a full terminal UI with Console/Chat, Trace, Tools, Metrics, Events, Approvals, Agents, MCP, Logs and Run tabs. Digits switch tabs, esc leaves the input, r restarts the program, s stops it, a / d approve or deny a pending tool call (for agents with a QueueApprovalHandler), q quits, ? shows help. The program runs in a child process, so nothing it prints draws over the UI; in script mode the console input goes to its stdin.
  • --watch / -w -- restarts the program when a project file changes ([dev] watch, [dev] ignore), like nodemon.
  • --prod and --check -- JSON log lines on stderr (stdout stays the program's output), run events and, with [prod] observe, every timeline event as JSON log lines, and a graceful SIGTERM (a script shuts down as on Ctrl+C and exits with 143; the HTTP API drains, giving in-flight requests [prod] shutdown_timeout seconds, and exits with 0). Every run checks the manifest, the environment and the entry first and lists every problem (exit 2); --check also compiles the script or imports the factory, then exits 0 or 2.
  • --serve -- an HTTP API for the agent factory: GET /healthz, GET /readyz, GET /v1/info, POST /v1/invoke ({input, session_id?} → {output, duration_ms, usage, request_id}), POST /v1/stream (Server-Sent Events). Bearer auth on /v1/* from [serve] auth_token_env (default PROMPTISE_SERVE_TOKEN), required on a non-loopback host; without a token, loopback-only Host and no cross-site Origin; 429 beyond [serve] max_concurrency; 503 while draining after SIGTERM; errors never include the exception, only an error_id that is logged.
  • promptise init --project [DIR] [--name NAME] [--model MODEL] [--force] -- creates a project: promptise.toml (with dev, ui, start, serve and check scripts and the model provider's API key in [env] required), agent.py (a create_agent factory with one local tool), main.py (a normal script that also runs with python main.py), .env.example, README.md, and .env / .promptise/ / __pycache__/ added to .gitignore. It refuses to overwrite files without --force, listing every conflict. Also available as promptise.run.scaffold.scaffold_project().
  • Example project -- examples/run/support_bot/: a support agent with three local tools that works with promptise run, --devtools, --tui and --serve.

Changed

  • promptise run without --model-id now runs a project. The inline REPL (promptise run --model-id ... [--stdio/--http/--instructions/--trace/--raw]) is unchanged.

v1.3.5 — 2026-10-15

Fixed

  • Code-action: a max_tool_calls too small for the job gave a wrong answer, not an error -- the prompt said "the program may make at most N function calls ... so plan the calls you need", so models counted their own calls and stopped early (if calls >= 50: break, orders = orders[:49]): the program never reached the cap, exited cleanly, and its partial result was the answer (3 of 46 benchmark programs did this at the default budget). And after a program did reach the cap, the repair asked for "a program that needs at most N calls", so the model wrote one that read only the first N records, which succeeded (5 of 5 runs of the guide's max_tool_calls=20 demo answered wrong, none raised). The prompt now says each program has a call cap but not its value, and tells the model never to skip, sample or truncate records, never to count its calls, and to let ToolBudgetExceeded raise; the error a refused call raises in the program no longer gives the value either. The repair after a budget hit asks for a program that does the whole job with fewer calls (batch tools, no repeated fetches) or the reply CANNOT_FINISH_WITHIN_CALL_CAP. That reply, or a budget hit with no repair left, raises the new CodeActionBudgetError (see Changed). An answer from a program written after a budget hit is logged at WARNING. Promptise still cannot tell whether a program that stays within the cap read all the data; the guide says so.
  • Audit log: a role or guard denial was recorded as an authentication failure -- a guard denial raises AuthenticationError with code="ACCESS_DENIED", and the client got ACCESS_DENIED, but AuditMiddleware recorded only error: "AuthenticationError: ...", so a verified caller without the role read exactly like a failed login, and anyone counting failed logins with error.startswith("AuthenticationError") counted every denial. An entry for a call that raised an MCPError now also has error_code, the code the client received (ACCESS_DENIED, AUTHENTICATION_ERROR, VALIDATION_ERROR, RATE_LIMIT_EXCEEDED, TOOL_ERROR, ...). error is unchanged. error_code is inside the HMAC chain; logs written by earlier versions have no error_code, still verify, and a 1.3.5 server continues their chain. The audit docs now also say that with require_auth=True, a request with a missing or invalid token is rejected with 401 by the HTTP gate before the MCP layer, so it is only in the HTTP access log, never in the audit log (by design, unchanged).

Changed

  • New CodeActionBudgetError (promptise.engine) -- a code-action run that cannot do the job within max_tool_calls raises it, with max_tool_calls, attempts and report. It is a GraphExecutionError, so existing handlers still catch it; budget failures that raised a plain GraphExecutionError in 1.3.4 raise this subclass. The code-action prompt and repair message changed (see Fixed): a model that would have answered from part of the data now ends the run with this error.

v1.3.4 — 2026-10-15

Fixed

  • max_agent_iterations=N ran N+1 model calls -- the engine counted a node execution after running it and stopped only once the count passed the limit, so build_agent(max_agent_iterations=N) (the engine's max_iterations) allowed N+1 node executions: N+1 model calls and N+1 rounds of tool calls in the default ReAct graph. N now allows exactly N node executions, which in the ReAct graph is N model calls. The build_agent docstring and the engine docs say what is counted.
  • BudgetHook stopped a run silently and returned a raw tool result as the answer -- when the budget ran out in the middle of a tool loop, ainvoke() returned normally with the last tool result as the last message and last_report.error unset. Such a run now raises GraphBudgetExceededError (a new GraphStoppedError, itself a GraphExecutionError) with reason ("Token budget exceeded: 5078/5000 tokens"), stopped_by, pending_tools and the report, like GraphIterationLimitError for iteration limits. Any hook that ends a run the same way (CycleDetectionHook, a custom hook) raises GraphStoppedError. A budget reached on the node that answered still returns the answer.
  • One BudgetHook capped an engine's lifetime, not a run -- tokens_used was never reset, so after the first run hit the cap every later run on the same engine stopped after its first node. The hook now counts the run's own tokens (GraphState.total_tokens), so every run, concurrent ones included, gets the whole budget; tokens_used / cost_used report the latest run.
  • Context compaction could make a model re-read the same results until its budget ran out -- a result longer than keep_result_chars was cut in the ledger with a note telling the model to call the tool again for the full text, and the re-read was cut again as soon as the next exchange started, so a model that re-read cut results one at a time could never have them all in full (reproduced with a scripted model: the default ReAct agent looped to its iteration limit). A call the model made more than once is now shown in full in the ledger (and shrunk last under max_tokens), and the excerpt says how much was cut and that a re-read is kept in full.
  • Schema-invalid tool calls skipped the server pipeline over HTTP and stdio, and echoed the whole input -- the MCP SDK (1.10+) validated a call's arguments against the tool's inputSchema before Promptise saw the call, so an invalid call was never authenticated, rate limited or audited, and came back as plain text, Input validation error: <jsonschema message>, quoting the offending value in full (a 1 MB string against max_length=10 produced a 1 MB error). The SDK's check is now off; the server validates every call itself, inside its pipeline, on every transport and in TestClient, and the error is the structured VALIDATION_ERROR envelope.
  • VALIDATION_ERROR envelopes could grow with the input -- one entry per invalid item, so a list of 50,000 bad values produced a 9 MB error. The envelope now lists at most 20 field errors (details.omitted_errors counts the rest), clips field paths and messages, and caps the message at 1,000 characters; it never includes the rejected value.
  • Redacting credentials corrupted JSON -- the connection-string patterns (postgres://, mysql://, mongodb://, redis://) and the password= / secret= patterns ran to the next whitespace, so on a JSON tool result they swallowed the closing quote, brace and comma: {"config": {"db": "postgres://..."}, "region": "eu"} became {"config": {"db": "[POSTGRES_CONNECTION] "region": "eu"}. Those patterns now stop at quotes and backslashes (URLs also at <>{}), and when the scanned text is a JSON document a redaction replaces text inside a string value only: the result parses to the same structure, JSON encoded inside a string stays valid, escape sequences are never split, and a number that has to be redacted becomes a quoted label.
  • mcp 2.x clients logged a 31-error WARNING on every connection to a Promptise server -- an mcp 2.x client (current Pydantic AI, OpenAI Agents SDK) opens with server/discover, which the 1.x SDK failed to validate, logging Failed to validate request: 31 validation errors for ClientRequest (94 lines) before answering -32602. The server now answers a request for any method it does not implement with -32601 Method not found and logs it at DEBUG; the client falls back to initialize as before. A notification of an unknown method is ignored. Works with every supported mcp version (>=1.9,<2).
  • The Quick Start's first agent raised TypeError -- "Your First Agent" calls build_agent(model=..., instructions=...), but servers was a required keyword, so the first example a new user ran failed with build_agent() missing 1 required keyword-only argument: 'servers'. servers is now optional (see Changed).
  • The Quick Start's "Add Tools" example never started its tool server -- the agent launched tools.py without --serve, so the child process ran the agent again (which launched another tools.py, and so on) instead of serving its tools, and the MCP handshake never happened. The example now passes args=["tools.py", "--serve"]. The same example in the blog posts "What is MCP", "Build an AI agent with Promptise" and "How to build an AI agent in Python" is fixed too (the last one also never called server.run()). Every python block on the Quick Start page now runs in the test suite with a scripted model, the stdio example against its real server.
  • Health: one anomaly silenced every other anomaly of its kind for 5 minutes -- HealthConfig.cooldown (300 s by default) was kept per anomaly type, so once the agent was reported stuck on one call, getting stuck on a different call (in the same run or in the next runs) was not reported: no anomaly, no health.anomaly event, no on_anomaly action. The cooldown now applies to the same anomaly only: the same stuck call (tool and arguments), the same loop (whichever call of the cycle it is caught at), or the same empty-response / error-rate signal. The health docs now explain the cooldown and how to use HealthMonitor as an in-run guard in a pre_tool hook (cooldown=0, so every repeat is reported).
  • The mission judge printed "No tools discovered" on every evaluation -- build_agent() printed [promptise] No tools discovered from MCP servers; agent will run without tools. to stdout whenever an agent had no tools, and the mission judge is built without tools, so a process with a mission wrote that line into the application's output once per evaluation. See Changed.
  • promptise audit verify --strict summary grammar -- with one warning it read "1 warning fail the check"; it now reads "1 warning fails the check under --strict".
  • Open mode: the model was not told that a tool it creates is usable only from its next run -- the run that calls create_tool keeps the agent and tool list it started with, but create_tool answered Tool 'x' created. Rebuild successful and its description said nothing about when the tool appears, so models kept looking for the tool and creating it again. See Changed.
  • Docs: sandbox_custom_tools=False does not give agent-written code full Python builtins -- docs/runtime/meta-tools.md said it did. The code runs with the same restricted builtins (no open, exec, eval) and may additionally import a fixed standard-library allowlist (json, math, re, datetime, ...); the docs and the _build_dynamic_tool docstring now say so.

Changed

  • The "Available tools" block is no longer added to the system prompt by default -- every PromptNode with tools appended a summary of them (parameters and the first 120 characters of each description) to its system prompt, on top of the tool definitions the provider already receives. On the 40-tool test server that is 997 tokens on every model call (a third on top of the 3,043 tokens of tool definitions); a 48-tool guide run measured 1,882 of a 6,843-token first request. New tool_summary option on build_agent, PromptGraphEngine and PromptNode: "off" (the default), "names" (one line of names, 178 tokens on the same server) or "full" (the previous block). Set tool_summary="full" to keep the 1.3.3 prompt. A string default now renders quoted (cursor: string="", was cursor: string=).
  • The compaction ledger no longer invites re-reading "something that changes over time" -- its header now tells the model to use the listed facts and call again only for the rest of a cut result or when it is waiting for something to change, such as a job's status. The managed pattern's instructions say the same. Repeated calls still run the tool, so polling works as before.
  • max_agent_iterations / PromptGraphEngine(max_iterations=...) allow one node execution fewer than in 1.3.3 (see Fixed): an agent that needed exactly N+1 steps under a limit of N now raises GraphIterationLimitError; raise the limit by one to keep the old allowance.
  • A BudgetHook (or other hook) that stops a run mid tool loop raises instead of returning (see Fixed). Catch GraphBudgetExceededError / GraphStoppedError.
  • Input validation runs inside the middleware chain -- a call whose arguments fail validation now goes through the server's middleware (authentication, rate limiting, audit, in your order) and the tool's guards before it gets its VALIDATION_ERROR, on the live transports and in TestClient. An unauthenticated caller now gets AUTHENTICATION_ERROR rather than a schema error, invalid calls count against rate limits, and AuditMiddleware records them (status: "error", no args). Middleware sees ctx.state["_tool_arguments"] == {} for such a call; the handler, dependency injection, CacheMiddleware and ApprovalGateMiddleware never see the rejected input (no cache lookup, no approval request). Resource templates and prompts follow the same order.
  • build_agent(servers=...) is optional -- it defaults to None, the same as servers={}: an agent with no MCP tools (just the model, or extra_tools).
  • build_agent() no longer prints to stdout when an agent has no tools -- with no servers configured nothing is reported (a DEBUG log record on promptise.agent); when servers are configured but none of them offers a tool, a WARNING naming the servers is logged on promptise.agent instead of the old print.
  • Health cooldown is per anomaly, not per anomaly type -- see Fixed. A process whose agent gets stuck on several different calls now reports each (one health.anomaly event each, and the on_anomaly action for the run), where it reported only the first within cooldown. Repeats of the same anomaly are still reported once per cooldown, also across runs.
  • Open mode: create_tool and connect_mcp_server tell the model the change applies from its next run -- create_tool now answers Tool 'x' created. You can call it from your next run, not in this one: this run keeps the tools it started with, so finish the current task without it. (it was Tool 'x' created. Rebuild successful), and connect_mcp_server answers Connected to MCP server 'x' at <url>. Its tools are available from your next run, not in this one: ... (it ended with Rebuild successful). Both tool descriptions say the same, and create_tool's description no longer says run(**kwargs) -> str (declared parameters are passed to run as keyword arguments).

Documentation

  • docs/core/engine-prebuilts.md, reasoning-patterns.md, engine-nodes.md: the ledger "cuts redundant tool calls at equal accuracy" claim is now conditional on the model, with the guide's measurements (gpt-4.1-mini re-read records under 1.3.3 compaction; gpt-5-mini did not).
  • docs/core/tool-optimization.md: new "The tool summary in the system prompt" section with token measurements.
  • docs/core/engine-hooks.md: BudgetHook is per run, may overshoot by one node, and raises GraphBudgetExceededError mid tool loop.

v1.3.3 — 2026-10-11

Fixed

  • The mission LLM judge always failed -- MissionTracker built its judge with build_agent() without the required servers argument, so every evaluation raised TypeError before any model call and was recorded as achieved=False, confidence=0.5, reasoning="Evaluation failed: build_agent() missing 1 required keyword-only argument: 'servers'". With the default confidence_threshold (0.7) the mission went to paused after its first evaluation, evaluations stopped, and auto_complete could never trigger. The judge is now built with no servers and evaluates the evidence; it is shut down after each evaluation. MissionTracker.evaluate() also accepts a chat model instance as model.
  • MissionConfig.max_invocations was never enforced -- a mission ran past its limit indefinitely. The mission now fails once it reaches max_invocations successful invocations: right after the last one (once its evaluation has run, so a mission completed by that run is not failed), with a mission.failed event whose reason is "max_invocations", and later invocations are skipped, as for timeout_hours.
  • Health on_anomaly was skipped unless the anomaly was less than 5 s old when the run ended, and never applied to a run that raised -- AgentProcess acted only on an anomaly detected in the last 5 seconds, and only after a successful run. A stuck agent keeps calling after the third identical call, so by the end of its run the anomaly was older: no pause, no escalation, no health.anomaly event. A stuck loop that ended in GraphIterationLimitError (or any error) never reached the check, and a high_error_rate anomaly was acted on only if a successful run ended within 5 s. Each anomaly is now acted on once, when the run that produced it ends, whether it succeeded or raised, however long ago it was detected; high_error_rate is acted on when the failure is recorded. A later healthy run no longer re-applies the action for an anomaly that was already handled (it did within the 5 s window). Every new anomaly emits its own health.anomaly event; the action runs once per run, for the latest.
  • GET /api/v1/processes/{name}/journal was always empty for processes with the default process_id -- the endpoint read entries by process_id while the process files them under its name (so they survive a restart), and AgentRuntime.add_process() gives a random UUID process_id. It now reads by name.
  • PATCH /api/v1/processes/{name}/instructions changed nothing until a restart -- it answered "effective_from": "next_invocation" but only set fields read when the agent is built (start(), a restart, an open-mode hot reload). The next invocation now rebuilds the agent with the new instructions before it runs, keeping the connected MCP servers, conversation history and process state; an invocation already running finishes with the old ones. The new AgentProcess.update_instructions(text) does the same from Python. The new instructions replace any an open-mode agent set for itself (rollback() keeps them), and a non-string instructions value is refused with 422 INVALID_CONFIG.
  • AgentProcess never closed its MCP client, so every stop, restart and hot reload leaked the previous connection and its stdio servers -- the process opens its own MCPMultiClient for the servers in its config, and nothing closed it: each stop()/start() cycle, restart-policy restart or open-mode hot reload left the previous stdio server processes running (still stuck in a hung call, if they were). stop() and restarts now close the client after shutting the agent down, and a hot reload closes the replaced agent and its client once the invocation that asked for the reload has finished with them (that run keeps the tools it started with). If a hot reload's rebuild fails, the process keeps its previous agent (it was shut down before the rebuild); a client whose agent fails to build is closed.
  • An agent identity no longer overrides a server's api_key -- build_agent(identity=...) (and AgentProcess) presented the identity's bearer to every server without a bearer_token, including one configured with HTTPServerSpec(api_key=...). The client sent Authorization: Bearer <identity token> next to x-api-key, the server's auth gate checked the bearer first, and the connection failed with 401, so one agent could not use identity-authenticated servers and API-key servers together. An explicit per-server credential now always wins: bearer_token, api_key, or an Authorization / x-api-key header in headers.
  • The built-in token endpoint and MCPClient.fetch_token() speak OAuth 2.0 (RFC 6749) -- enable_token_endpoint() read only a JSON body and answered a standard form-encoded client_credentials request with 400, and fetch_token() posted JSON without grant_type, so it failed against a standard token endpoint. The endpoint now accepts application/x-www-form-urlencoded requests and HTTP Basic client authentication, answers errors with the standard error_description (message is kept), and sends Cache-Control: no-store. JSON bodies are still accepted. fetch_token() sends a form-encoded grant_type=client_credentials request, and retries once as JSON when a built-in endpoint from 1.3.2 or earlier asks for JSON.
  • Tool name collisions: the list matches the routing -- when two servers expose a tool with the same name, MCPMultiClient.list_tools() (and so the agent's tool list) contained every copy, while calls went to the server listed last and the warning said only that version is used. It now returns only that server's copy; list_prompts() likewise. A server whose session is re-opened no longer takes a colliding name over from a server listed after it.
  • promptise audit verify --strict no longer prints "OK ... intact." when it fails -- with only warnings (a truncated last line, say), the summary said OK while the exit code was 1. It now reads FAILED ... no tampering found, but N warning(s) fail the check under --strict, and --json adds strict and passed (the exit code's verdict) next to ok (the chain's verdict).
  • A built-in identity guard implies auth=True -- guards=[RequireAuth()] (or HasRole, HasScope, RequireClientId, the tenant guards) on a tool without auth=True denied every caller, valid credentials or not, because AuthMiddleware authenticates only auth=True tools. Such a guard now implies auth=True, as roles=[...] already did, on tools, resources, prompts and router-level guards. Custom guards are unchanged.
  • Custom auth providers can hand their claims to AuthMiddleware -- the documented ctx.state["jwt_payload"] = claims was ignored (AuthMiddleware read only the built-in providers' private _jwt_payload), so roles, scopes and tenant from a custom provider never reached ctx.client, and roles=[...] denied every call. ctx.state["jwt_payload"] is now the public key for a custom provider's verified claims.
  • MCPClient no longer triggers the MCP SDK's DeprecationWarning -- with mcp 1.30 every HTTP connection warned Use streamable_http_client instead. The client now uses streamable_http_client when the installed SDK has it and falls back to streamablehttp_client on older SDKs (down to the minimum, mcp 1.9).
  • Per-client rate limits that collapse into one shared bucket are reported -- RateLimitMiddleware keys its buckets by ctx.client_id, which is set only when AuthMiddleware runs before it and the tool is authenticated; otherwise every caller silently shared one "global" bucket, and one runaway client throttled all the others. When an AuthMiddleware is installed but cannot identify the caller for the limiter (it runs after the limiter, or the tool is not auth=True), the server now logs a warning naming the tools, once, at startup (and on TestClient's first call). A key_func marks the key as deliberate and is not reported. A limiter without a synchronous consume(key) method now raises TypeError when RateLimitMiddleware is created, instead of failing every call with INTERNAL_ERROR.
  • A forwarded caller token no longer overrides a server's api_key -- forward_caller_token is on by default, so during an invocation whose CallerContext carries a bearer_token, every tool call (and read_resource / get_prompt) to a server configured with HTTPServerSpec(api_key=...) or an x-api-key header went over a session that sent Authorization: Bearer <caller token> next to x-api-key. The server's auth gate checked the bearer first and answered 401, so an API-key server failed every call made for a user with a token: the agent-identity bug above, again. An API key now wins by default and the caller's token is not sent to that server. HTTPServerSpec(forward_caller_token=True) still forwards it, explicitly: both headers are then sent and the server decides which counts (a Promptise server checks the bearer first). The resolved choice is HTTPServerSpec.forwards_caller_token.
  • A .agent manifest dropped forward_caller_token -- manifest_to_process_config() did not carry it to the HTTPServerSpec, so a manifest could not opt a server out of receiving caller tokens. It is carried now, as AgentProcess's own dict normalisation already did.
  • agent_pattern="debate" replied with the judge's verdict, never looped, and had no tools -- its critic and judge answered in plain text (no output_schema), so the critic's severity never sent the proposer back (it had no revise route either), the judge's on_fail never fired, and the run went proposer → critic → judge once and replied with the judge's text ("1) Does it pass ALL criteria? False ..."). build_agent also built it without the agent's tools. Critic and judge now return structured output (DebateCritique, DebateVerdict in promptise.engine.prebuilts): a severity above 0.3 or a failed verdict sends the answer back to the proposer, which sees the critique and verdict and has the agent's tools; a new answer stage writes the reply. Every stage has a budget (critic and judge max_rounds, proposer max_proposer_steps), so a debate always ends with a written answer. Same bug class as the 1.3.0 PEOATR fix.
  • agent_pattern="deliberate" replied with its reflection and never looped -- plan and reflect answered in plain text, so the plan's quality gate and reflect's continue / replan / answer route never fired: every run went think → plan → act → observe → reflect once and replied with the reflection ("1) Overall progress ... 2) Mistakes ..."). Plan and reflect now return structured output (PeoatrPlan, PeoatrReflection), so their routes fire, every stage has a budget (plan 3, act max_act_steps, reflect 4), and a new answer stage writes the reply.
  • agent_pattern="research": the verify step could not fail -- it was a GuardNode with no guards: it passed every time without a model call (the "cross-check the findings" instructions it was given were never used), so the documented loop back to search never happened. Verify is now a model judgement (ValidateNode) that checks the findings against the tool results in the conversation and returns a ResearchVerdict (passes, issues): every claim backed by a tool result, nothing contradicting the sources, the whole question covered. A failed check sends the run back to search, which sees the issues; verify runs at most max_verify_rounds times (default 2), then the run goes to synthesize. Search now has a budget for the whole run (max_search_steps, default 12; it was 5, and a search that used it ended the run without an answer) and hands over to synthesize when it is used.
  • agent_pattern="autonomous" could reply with routing JSON, named tools as steps, and ran one tool round per step -- every routing call appended the full planner prompt and step menu to the conversation as a new system message and the model's JSON choice as an AI message, so the prompts piled up and a run that finished could reply {"next_node": "__done__", ...} or a JSON error object; a chosen tool step ran one model call, so it made one round of tool calls and its model did not see the results unless the agent picked it again; and the model often named a tool ("get_policy" is not available). Now the routing exchange is sent for its one call and never added to the conversation; a chosen step runs its whole tool loop (re-run while its model calls tools, up to its max_iterations; the default reason step allows 10 rounds, max_tool_rounds); a tool name runs the step that has that tool, and an unknown name is reported to the next routing call only; and when the agent finishes without its terminal node having run last, that node runs (synthesize in the default pool, or the new AutonomousNode(answer_node=...); with no terminal node, the node asks the model for the final answer), so the reply is always a written answer. The default pool is built without tools too (it was empty, and the run failed with "No nodes in the pool"). AutonomousNode now streams its steps' model chunks and tool events, so astream_with_tools() returns the answer (its DoneEvent was empty).
  • PromptGraph.peoatr() / .research() passed to build_agent as a graph ran as a node pool -- they were built in the default "autonomous" mode with no edges, so build_agent(agent_pattern=PromptGraph.peoatr(...)) wrapped their nodes in an AutonomousNode instead of running the graph. They are now static graphs, like the other built-in patterns.
  • Code-action: max_tool_calls did not count failed tool calls -- the cap compared it with the number of successful calls, so a program could call a failing tool without limit (331 calls ran under the default cap of 50; a program looping a failing tool 120 times under max_tool_calls=10 made all 120). Every call a program makes now counts: successful, failed, an unknown tool, or one a pre_tool hook blocked.
  • Code-action: reaching max_tool_calls was silent, and a repair could not recover from it -- the bridge answered the call with an error and logged nothing, so a program that caught exceptions finished with a wrong answer (report.error None); and the budget was shared with the repair attempts, so a program repaired after hitting the cap failed its first call (or printed a made-up result such as None spent $0.00). Now a call beyond the cap raises ToolBudgetExceeded in the program (a RuntimeError, without running the tool, and so does every later call), the node logs a warning, and the program's output is discarded even if it caught the error. The model is told the program needed more than max_tool_calls calls and asked for one that needs fewer; each repaired program gets a fresh budget, so a run makes at most (max_repairs + 1) × max_tool_calls calls. With no repair left the run raises GraphExecutionError, and ExecutionReport.error names max_tool_calls. The prompt states the limit. A crash's error now ends the final error message (program failed after N attempt(s); last attempt: ...).
  • Code-action: MCP tool results reached the program as JSON text -- an MCP tool answers with the text of the server's result, so list_orders("2026-09") arrived as the string '["SO-9001", ...]' and programs iterated its characters, although the docs said data arrives as lists and dicts. An MCP tool's text result that is valid JSON now reaches the program decoded (lists, dicts, numbers; other text stays a str), also behind the approval, guardrail and tracing wrappers. A Promptise error envelope ({"error": {"code": ..., "message": ...}}) raises in the program as an MCPToolError, like an MCP error result. Python tools are unchanged: a str they return stays a str.
  • Code-action: ExecutionReport.tool_calls was always 0 -- CodeActionNode recorded its calls without counting them. The report now counts the programs' calls, failed ones included, and each observation has its duration.
  • Sandbox cleanup always took 5 s -- SandboxSession.cleanup() stopped the container gracefully before removing it, and the container's /bin/bash (PID 1) ignores SIGTERM, so every cleanup waited out docker stop's 5 s timeout, blocking the event loop. Code-action paid it once per program and once more for build_agent's probe. A non-persistent session's container is now killed (new SandboxBackend.kill_container(), which defaults to stop_container()) and removed, about 0.1 s; a code-action program with no tool calls takes about 1 s in all. The Docker calls in stop_container() / remove_container() no longer block the event loop.
  • Open mode: create_tool ignored its parameters -- every agent-written tool took one input string, so a tool written as def run(a, b) failed with run() got an unexpected keyword argument 'input'. The declared JSON object schema is now the tool's argument schema: the model is offered its parameters, the arguments are validated (invalid ones raise ToolArgumentError), and run gets them as keyword arguments, as plain JSON, with each property's schema default for one left out. A parameters that is not an object schema (or lists parameters without properties, or names one that is not a valid Python keyword argument) is refused with an error and no tool is created. Empty parameters keep the free-form input string.
  • Open mode: a change refused by max_rebuilds was applied on the next restart -- the meta-tools set the new instructions, tool or server before the hot reload checked the limit, and the refused change stayed in the process's dynamic state, which the next start() (deploy, crash recovery, restart_policy) built the agent from. The limit is now checked before anything changes, under the rebuild lock, and a refused change is not made at all: modify_instructions, create_tool and connect_mcp_server answer Error: max rebuilds (N) reached; the change was not applied (create_tool and connect_mcp_server used to say the tool was created or the server connected). A change whose rebuild fails is undone too (a failed create_tool left its tool behind). The limit counts over the process object's lifetime, as documented; restarts do not reset it.

Documentation

  • Runtime reference corrected where it did not match the code: mission states are active / paused / completed / failed, eval_every defaults to 1, the injected [Mission] block and what a low-confidence or failed evaluation does (docs/runtime/governance/mission.md); the injected [Budget Remaining] block and the budget escalation payload ("type": "budget_violation", violation as an object with limit_name, limit_value, current_value, tool_name) (budget.md); on_anomaly accepts "log", "pause" and "escalate" (the docs listed "stop", which HealthConfig rejects), when the action runs, error-rate detection counts failed invocations, and the HealthMonitor method list (health.md); the instructions and journal endpoints (api.md), hot reload (meta-tools.md) and stop/restart (processes.md).
  • testing.md "Testing with authentication": passed algorithm= (not a JWTAuth parameter), relied on a guard without auth=True, and made a token without exp; it now runs, and the test suite runs it.
  • auth-security.md "Custom auth provider": stored the claims under a key AuthMiddleware did not read; it now shows the jwt_payload contract and runs.
  • caching-performance.md "Tiered rate limiting": defined async check(key, ctx), which RateLimitMiddleware never calls (every call failed with INTERNAL_ERROR); it now uses consume(key) with a key_func that puts the tier in the key. A new note says when buckets are per client.
  • showcase.md: the database and payments MCP server snippets used MCPServer(auth=...), AuthMiddleware() without a provider and RateLimitMiddleware(requests_per_minute=...), none of which exist, and passed "${JWT_SECRET}" / "${AUDIT_SECRET}" literally as keys. They read the keys from the environment and run.
  • approval-gates.md "Audit visibility": the approval request id is in the error the client receives and in the gate's log lines, not in the audit entry as the page said.
  • observability.md: a key rotation warning comes on the first audited call after startup, not at startup.
  • identity/guide.md: an explicit per-server api_key (or x-api-key header) wins over the agent identity, like bearer_token.
  • multi-user-systems.md and secure-multi-tenant-platform.md: AuditMiddleware(secret=...) raised TypeError (the parameter is hmac_secret), and JWTAuth, AuditMiddleware and PostgresConversationStore were given "${JWT_SECRET}"-style strings, which these classes take literally (only .superagent files and a few documented parameters expand them). The examples read the keys from the environment with os.environ[...] and the audit examples write audit.jsonl, verifiable with promptise audit verify. The 30-tool server in multi-user-systems.md also used MCPServer(auth=...), RateLimitMiddleware(requests_per_second=...) and server.mount(router), none of which exist, and an on_authenticate hook with the wrong signature; it runs now, and the test suite runs it and both audit examples. The same literal-key fix in config.md (HTTPServerSpec) and a blog post (JWTAuth); showcase.md's gateway example includes its routers with include_router().
  • Server Specs, Multi-User Identity, Multi-Tenancy, Building Agents and Tool Adapter pages: which credential a server sees for a caller with a token (forward_caller_token unset, True or False, with and without an API key).
  • agentic-runtime.md no longer shows MissionConfig(on_complete=...); mission.md lists it as deprecated.
  • reasoning-patterns.md and engine-prebuilts.md: debate, deliberate, research and autonomous described as they now run (debate's critic and judge loops and answer stage, deliberate's routes and answer stage, research's model-judged verify loop and its bound, autonomous's tool loops, routing and final answer); engine-nodes.md and engine-internals.md: AutonomousNode (answer_node, the tool loop per step, routing kept out of the conversation).
  • Code-action, engine prebuilts, reasoning patterns and building agents: the tool-call budget, MCP results arriving as data, and the latency per program (about 1 s, not 1-2 s). Meta-tools and configuration: how create_tool uses parameters, and what max_rebuilds counts and refuses. Sandbox: cleanup() kills the container, and kill_container().

Deprecated

  • MissionConfig.on_complete -- it was documented ("stop", "continue" or "suspend") but never read: a completed mission stopped the process whenever auto_complete=True, whatever on_complete said. Setting it now emits a FutureWarning; it will be removed. Use auto_complete (False keeps the process running after completion; its later invocations are skipped).

Changed

  • A tool, resource or prompt with a built-in identity guard is now authenticated: a caller without credentials gets AUTHENTICATION_ERROR where it got ACCESS_DENIED (it was denied either way).
  • MCPMultiClient.list_tools() / list_prompts() return one entry per name when servers collide (the copy calls are routed to).
  • A form-encoded request to the built-in token endpoint must carry grant_type (RFC 6749); a JSON request still defaults to client_credentials.
  • HTTPServerSpec.forward_caller_token defaults to None: forward the caller's token unless the server has an API key. A server with an API key no longer receives caller tokens unless it sets forward_caller_token=True; True and False behave as before.
  • PromptGraph.debate() has a fourth node, answer, and new parameters tools, blocks and max_proposer_steps; max_rounds now bounds the critic and the judge (default 3, was 5 for the proposer, which never looped). PromptGraph.deliberate() has a sixth node, answer, and new parameters blocks and max_act_steps. PromptGraph.research() takes max_search_steps and max_verify_rounds, and its verify node is a ValidateNode (a model call per check) instead of a GuardNode; pass verify=False to skip it. PromptGraph.autonomous() takes blocks and max_tool_rounds. The multi-stage patterns make one more model call than before: their answer stage (and research's verify).
  • Code-action's max_tool_calls caps each program, not the whole run: a repaired program gets a fresh budget (a run makes at most (max_repairs + 1) × max_tool_calls calls), and failed calls count. A program that reaches it fails instead of answering.
  • Code-action programs get MCP tool results that are JSON text decoded, so code that parsed the text itself (json.loads(list_orders(...))) now gets a list and must drop the parse.
  • AgentProcess.rollback() is no longer refused once max_rebuilds is reached (it cleared the dynamic state and returned the refusal, so the rollback happened only at the next restart); it counts as a rebuild. If its rebuild fails, the previous state is restored.
  • An open-mode tool created with parameters takes those parameters instead of input.

v1.3.2 — 2026-10-11

Fixed

  • Code-action: post_tool hooks got their arguments swapped and could not rewrite results -- CodeActionNode called post_tool(name, args, result, state) instead of the documented post_tool(tool_name, result, args, state) and ignored what the hook returned, so a hook that redacts or truncates tool results had no effect on code-action agents. The hook now gets the documented arguments, and a returned string replaces the result the generated program receives and the run records.
  • A repeated tool call got a stale result once a tool loop compacted -- once a run had 6 tool results (the default ReAct agent's context_scope="auto" switching to the ledger), and on every call of a context_scope="ledger" node (agent_pattern="managed"), a call with the same tool and arguments as an earlier successful call got that earlier result back and the tool never ran. An agent polling queue_status(job_id) or a health check saw "running" until its iteration limit although the job had completed, the server never got the requests, and trace_tools, observer, callbacks and engine hooks showed nothing for those calls. A repeated call now runs the tool. Answering a repeat from the earlier result is an explicit opt-in for tools whose result cannot change during a run (see Changed), and a call answered that way is reported, marked as reused. The ledger's note to the model now says each entry is the result as it was when the call ran and that the current value of something that changes, such as a status, can be fetched again (it said not to call a tool again for the listed results); the managed pattern's prompt says the same.
  • A run stopped by an iteration limit in the middle of a tool loop returned a tool result as the answer -- when the ReAct reason node used its 15 iterations (or the run reached max_agent_iterations) right after calling tools, the engine logged "Stuck node 'reason' — forcing graph end" and the run ended on that tool result, so result["messages"][-1].content was the raw tool output, such as a queue_status JSON, instead of an answer. Such a run now raises GraphIterationLimitError (new in promptise.engine, a GraphExecutionError) with the node, the limit and the tools whose results the model never saw (pending_tools); ExecutionReport.error carries the message. astream() and astream_events() raise it after their output, and astream_with_tools() ends with an ErrorEvent. A limit reached after the model answered, or one the graph routes to an "error" transition or an __error__ node, ends the run as before.
  • ToolNode(deduplicate=True) treated calls from earlier runs as duplicates and left them without a result -- the node kept its record of past calls on the node object, which every run of the graph shares. A call with the same tool and arguments as any call the node had made before, in the same run, an earlier run on the same agent or a concurrent one, did not run, and no ToolMessage was written for its tool_call_id (NodeResult.tool_calls recorded it as "DUPLICATE: identical call already made"). The model got no result for the call, which providers that need one result per call reject, and a status check the model repeated in a later turn never reached the tool. Deduplication now covers only the calls of one model turn, and its record belongs to the run, not the node. When the model asks for the same tool with the same arguments twice in one turn, the tool runs once and each repeat gets its own ToolMessage with that result, reported as reused the way ContextCompaction(reuse_results=...) reports it: trace_tools, the observer, callbacks (metadata["promptise_reused"]), engine hooks (on_tool_reused) and "reused": True in NodeResult.tool_calls. A call in a later turn, a later pass of a tool_selector, or another run runs the tool, and a failed call is never reused. If the graph reaches the node again before the model's next turn, calls that already have a result are not run again.
  • ToolNode calls were missing from astream_events() and astream_with_tools() -- a ToolNode emitted no on_tool_start / on_tool_end events, so in a custom graph that runs tools with one, a streamed run showed no ToolStartEvent / ToolEndEvent for its calls, reused calls included, and DoneEvent.tool_calls was empty. A ToolNode now streams each call with the same events and fields as a PromptNode: tool_index, duration_ms, success, reused, and for a failed call the error's summary. It runs a tool the same way too: as a tool call, so a tool with an InjectedToolCallId argument works (it raised before), and an MCP tool error reaches the model as the server's error envelope with its code ({"error": {"code": "TOOL_ERROR", "message": ...}}) in a ToolMessage with status="error", where it was "Error: MCPToolError: ..." without a status. A tool that handles its own ToolException and an unknown tool count as failed, a non-string result reaches the model as JSON (it was a Python repr), and every ToolMessage either node writes carries the tool's name. The calls of one turn still run one after another.
  • Engine hooks' pre_tool / post_tool never ran for ToolNode calls -- a hook that rewrites arguments, audits results or refuses a tool had no effect on calls a ToolNode made. They now run around each call exactly as for a PromptNode: pre_tool returns the arguments to use, post_tool the result the model sees, and a pre_tool hook that raises blocks the call (the tool does not run, no post_tool, the model gets "Error: <message>", and the call counts as failed). A repeat answered from the turn's earlier result gets on_tool_reused instead, as before. The rules are now documented under Tool Hooks in the engine hooks guide.
  • ToolNode(validate_inputs=True) did nothing -- the parameter (on by default, documented as "validate tool arguments before calling") was never used, so invalid arguments reached the tool and came back as a raw pydantic ValidationError, and a null for an optional parameter of a Python tool failed the call. The node now validates each call's arguments against the schema the model is offered for the tool, after pre_tool hooks (an MCP tool, which has checked its own arguments since 1.3.1 and reports a rejected call to trace_tools and the observer, is left to do so). Invalid arguments do not reach the tool: the model gets the ToolArgumentError message from 1.3.1, each problem by argument path and an instruction to correct the call, in a ToolMessage with status="error"; the call counts as failed, streams as failed, is never reused, and callbacks get on_tool_error with the ToolArgumentError (code INVALID_ARGUMENTS). As for MCP tools, an explicit null for an optional parameter that cannot be null counts as not given. validate_inputs=False passes the arguments to the tool as they are.
  • The iteration-limit error named the tool "?" for a tool_selector graph -- when a run whose tool calls came from a ToolNode(tool_selector=...) hit an iteration limit, GraphIterationLimitError said "after a call to ? whose result the model never saw" and pending_tools was ["?"], or named a tool from an earlier model turn when the model had answered before. It now names the tools of the trailing tool results, including those a selector called.

Changed

  • Ledger compaction no longer answers repeated calls from earlier results by default -- since 1.1.0, context_scope="ledger" nodes (the managed pattern) served every repeated (tool, args) call from the earlier result, and so did the default ReAct agent ("auto") once its tool loop compacted. To keep that for lookups whose result cannot change during a run, opt the tools in: build_agent(context_compaction=ContextCompaction(reuse_results=["get_employee", "lookup_*"])) (tool names, * wildcards allowed) or reuse_results=True for every tool; PromptNode(compaction=...) takes it per node. It applies once the node compacts and never to a failed call. No MCP annotation turns it on: idempotentHint says a repeated call has no further side effects, not that its result stays the same. A reused call is reported like any other, marked as reused: trace_tools prints ↺ Reusing an earlier result for tool: ..., the observer timeline and observe=True record it with reused: true in the metadata, LangChain callbacks get its start and end with metadata["promptise_reused"] (promptise.engine.nodes.REUSED_TOOL_METADATA_KEY), engine hooks get the new optional on_tool_reused(tool_name, args, result, state), NodeResult.tool_calls records it with "reused": True (it was "cached": True), and astream_with_tools() ends it with a ToolEndEvent whose new reused field is True. reuse_results round-trips through save_graph() / load_graph().

v1.3.1 — 2026-10-11

Fixed

  • MCP tools sent null for every optional parameter the model left out -- LangChain's tool input parsing (langchain-core 1.x) fills in every field with a default, and the agent's tool schema gives an optional parameter without a schema default the default None, so the MCP tool sent {"limit": null, ...} for each one. A server that validates its input rejected the call (Input validation error: None is not of type 'integer'): every MCPcast-generated server, and any tool whose schema has an optional, non-nullable parameter. Only the arguments the model gave are sent now, at every level of nesting, and the server applies its own defaults. An explicit null is sent only where the schema allows null; a null for an optional parameter that cannot be null counts as not given (logged at debug level). The same applies to MCPcast's readiness run.
  • Invalid tool arguments failed silently -- a call whose arguments did not match the tool's schema (including an explicit null for an optional, non-nullable parameter) failed before it was sent, with a raw pydantic error for the model and nothing in trace_tools or the observability timeline. It now raises promptise.tools.ToolArgumentError (a ToolException, code INVALID_ARGUMENTS): the model gets each problem by argument path (- items.0.qty: Input should be a valid integer (got "two")) and is told to correct the call, trace_tools and the timeline show the call and the error, and a tool.error event carries the code. Behind an approval gate, invalid arguments are reported the same way and never reach a reviewer.
  • Nested tool arguments were model reprs downstream -- nested objects reached trace_tools, the observability timeline, ApprovalRequest.arguments / raw_arguments, approval.* events and adaptive-strategy denial lessons as generated model instances (Args_create_order_items_Item_2(sku='A', ...)), so reviewers saw reprs and ApprovalRule.argument_contains / predicates matched them instead of the data. They are plain JSON now (dicts and lists, the fields the model set). Python tools behind the approval gate, guardrail scanning or tracing still receive validated values (model instances for model parameters) and their own defaults.
  • Free-form MCP tool arguments were dropped -- a tool whose input schema has no properties but allows any keys (additionalProperties) was called with {}, whatever the model passed.
  • Redaction missed current OpenAI and Anthropic API keys, and OTel spans exported error text unredacted -- the credential pattern behind observability and event redaction matched only sk- followed by letters and digits, so sk-proj-…, sk-svcacct-… and sk-ant-… keys (which contain - and _) passed through. It now matches them. OTelMiddleware put the raw exception message on spans (mcp.error.message, the status description and the recorded exception, including its traceback); all three now go through the same redaction, and the span's own automatic exception recording is turned off so nothing is exported unredacted.
  • trace_tools=True printed every cross-agent delegation twice -- each ask_agent_<name> (and broadcast_to_agents) call and its result were printed, and recorded by an observer, once by the delegation hooks and again by the wrapper that traces non-MCP tools, although the peer ran once. Cross-agent tools now report only through their delegation hooks, so each delegation prints once, with the delegation's own arguments (and, for a broadcast, the per-peer results).
  • A failed astream_with_tools() run was not logged -- the consumer gets a generic ErrorEvent and the cause was dropped at every log level (1.2.1 logged it). The failure is logged again as one ERROR line on promptise.agent (the level the engine logs a failed run at) with the error type, message, agent id, session id and invocation id. The message goes through the observability redaction (API keys, tokens, URL credentials, card numbers, emails) and is truncated; a guardrail violation is summarised, not quoted. astream() and ainvoke() raise the failure to the caller and are unchanged.
  • request_more_tools() without arguments enabled the whole tool catalogue -- with semantic tool selection on a large server, the next model call could be offered hundreds of tools and fail on OpenAI's 128-tool limit. An argument-less call now returns the next semantic_top_k tools most relevant to the conversation that the model was not offered yet (each further call pages on). One call returns at most 32 tools (a longer tool_names list is cut, and the reply says how many were left out), and a model call is offered at most 100 indexed tools.
  • optimize_tools stripped more than descriptions from tool schemas -- schema minification (on at every level: "minimal", "standard", "semantic") rebuilt each tool's argument model from field types and defaults only. It dropped the constraints shown to the model (enum values a Literal cannot hold, pattern, minLength/maxLength, minimum/maximum, format, minItems/maxItems, ...) and the model config, so a free-form tool (no properties, additionalProperties) lost its "takes any keys" setting and was called with {} again. Minification now removes only descriptions and keeps types, required and optional fields and their defaults, nullability, constraints and other field settings, the model config, and nested models wherever they sit (Optional[list[Model]], dict[str, Model]). Optional parameters the model leaves out are still not sent, null handling and plain-JSON nested arguments work as without optimization, and max_schema_depth still flattens deeper objects to dict, keeping the field's own constraints and nullability.
  • Top-level parameter constraints and free-form schemas did not reach the model -- LangChain builds the schema it offers the model (BaseTool.tool_call_schema) by copying each field's type, default and description, which drops json_schema_extra and the model config. Constraints on nested objects reached the model, those on top-level MCP tool parameters did not, and a free-form MCP tool was offered as a tool without parameters ({"properties": {}}), with or without optimize_tools. MCP tools now offer their argument schema as is, and the approval gate, guardrail scanning and tool tracing wrappers offer what the tool they wrap offers.

Documentation

  • MCP server OpenTelemetry example -- the docs showed OTelMiddleware(endpoint=...), which raises TypeError: the middleware takes service_name, tracer_provider and meter_provider, and the exporter (and its endpoint) is configured on the provider. The examples now build an OTLP TracerProvider and run as written; the span attribute is mcp.status (not mcp.tool.status). The docs also said OTelMiddleware and PrometheusMiddleware do nothing without their packages; they raise ImportError, whose message now names the extra (pip install "promptise[all]") as well as the packages. The PrometheusMiddleware docstring no longer promises a /metrics route the server does not add.

v1.3.0 — 2026-10-11

Upgrading from 1.2.x

1.3.0 is a security and correctness release. Most of it needs no code changes, but several defaults now fail closed. Check this list before upgrading:

  • MCP servers listen on 127.0.0.1 by default. server.run(transport="http") without host= is no longer reachable from other machines or containers. In Docker or behind a load balancer, pass host="0.0.0.0" and set allowed_hosts. A public bind without AuthMiddleware or allowed_hosts logs a warning at startup.
  • CORSConfig(allow_origins=["*"], allow_credentials=True) raises ValueError. List the origins that may send credentials.
  • Resources and prompts go through auth, guards and middleware like tools. require_auth=True, require_tenant, router auth and guards, and rate limits now also cover resource reads and prompt requests, so a client without a token gets an MCP error where it used to read them. Resources and prompts can take auth=, roles= and guards= like tools.
  • Guardrails fail closed. guardrails=True / PromptiseSecurityScanner.default() needs transformers and torch. If a model-backed head cannot run, scans are blocked instead of passed.
  • Sandbox fails closed. build_agent(sandbox=...) raises when the sandbox cannot start (no Docker client, Docker not running, gVisor missing) instead of building the agent without sandbox tools. The default network is "none".
  • Identity credentials fail closed. When an agent's identity cannot get a token (IdP down, expired file), the call fails with MCPCredentialError; nothing is sent unauthenticated.
  • Runtime transport and API need a token on public binds. RuntimeTransport(host="0.0.0.0") without auth_token raises (pass allow_unauthenticated=True to opt out). OrchestrationAPI rejects an empty auth_token. On loopback, both refuse browser-originated requests (cross-site Origin, non-loopback Host).
  • ProcessConfig, TriggerConfig and TriggerDeliveryConfig reject unknown keys, and ProcessConfig.journal is off by default.
  • MCP tool errors raise. An MCP tool that returns an error result raises MCPToolError when called directly (tool.ainvoke(...)); inside an agent the model still sees the server's message, and the call counts as failed.
  • Event webhook signatures changed. Receivers must verify the new t=...,v1=... header with promptise.events.verify_event_signature(); the old bare-hex signature is gone.
  • Webhooks to private addresses are refused at send time, including after DNS changes. Set allow_private_networks=True on the sink or handler for internal receivers.
  • Semantic cache stores less. Follow-up turns and turns that called tools are not cached by default (cache_multi_turn=True, cache_tool_turns=True for read-only tools), scope="per_session" needs a user_id, and build_agent(cache=...) raises ImportError when the cache's packages are missing.
  • Cross-agent delegation. broadcast_to_agents is opt-in (include_broadcast=True), the model can no longer pick a timeout, and delegation stops at max_delegation_depth (default 3). .superagent files reject auth: on HTTP servers (use bearer_token: / api_key:), and promptise validate fails on missing environment variables.
  • Reasoning Graph. Pydantic structured outputs are dicts in result.output and state.context; a PromptNode stops at its own max_iterations (default 10); unknown agent_pattern names raise ValueError.
  • Observability redacts secrets and personal data in traces by default (ObservabilityConfig.redact_sensitive=False to turn it off), and prompt text is recorded only at FULL unless record_prompts=True.
  • Audit logs. AuditMiddleware(log_path=...) with signing on needs a key (PROMPTISE_AUDIT_SECRET or hmac_secret=); without one it raises instead of signing with a random per-process key nobody could verify. A restarted server continues the file's chain instead of starting a new one, and entries carry seq. Verify a file with verify_audit_log() or promptise audit verify.
  • RAG. rag_to_tool(format="json") returns {"notice": ..., "results": [...]} instead of a bare list, and markdown/text output is fenced as untrusted data. Indexing a document again replaces its chunks. A RAGPipeline is one shared corpus: scope retrieval per caller yourself (see Who can retrieve what in the RAG guide).
  • Adaptive strategy keeps lessons per caller (scope="per_user"); lessons saved by 1.2.1 or earlier carry no scope and are no longer shown. Re-add the ones you need.

Added

  • MCP client: bearer_token_provider for short-lived credentials -- MCPClient(bearer_token_provider=...) takes a callable (sync or async) that returns the current token and is asked on every HTTP request, instead of a fixed bearer_token. When the server answers 401, the provider is called with force_refresh=True and the request is re-sent once with the new token (concurrent rejections share one refresh). A session opened with a token the provider has since renewed is reopened before the next call; calls still running on the old session finish there and are never re-sent. HTTP and SSE only. New MCPCredentialError (a MCPClientError) when the provider cannot supply a token.
  • Identity: get_credential(audience, force_refresh=True) -- on AgentIdentity and every provider, skips the cached credential, for when a resource rejected it before its exp (rotated or revoked key, clock skew). Active providers (from_entra, from_aws STS, from_gcp, from_spiffe) now know their default audience (provider.default_audience), so get_credential() and get_credential(<default audience>) share one cached credential. JwksAuth.from_discovery() accepts leeway like the constructor.
  • Core: build_superagent() builds a whole .superagent team -- await build_superagent("coordinator.superagent") (also from promptise import build_superagent) builds the agent and every agent under cross_agents:, at any depth, each from its whole file (servers, approval, guardrails, memory, identity, its own cross-agents), in the running event loop. shutdown() on the result shuts down the whole team; if one agent fails to build, the ones already built are shut down and the error names the file. SuperAgentLoader.cross_loaders holds the resolved references at every depth, and SuperAgentConfig.to_build_kwargs(cross_agents=...) takes built peers.
  • Cross-agent: delegation limits and timeouts set in Python -- build_agent(max_delegation_depth=3, delegation_timeout=None, include_broadcast=False), CrossAgent(timeout=...), and the same in .superagent files (max_delegation_depth:, delegation_timeout:, include_broadcast:, and timeout: on each cross_agents entry). A call deeper than max_delegation_depth, or back into a peer that is already working on the request, raises DelegationError (exported from promptise), which the model sees as the tool's error. promptise.cross_agent.get_delegation_chain() returns the peers working on the current request. CrossAgent is exported from promptise.
  • .superagent files: bearer_token, api_key and audience on HTTP servers, webhook_secret on approval -- the credentials are sent as Authorization: Bearer <token> and x-api-key: <key> (both support ${ENV_VAR}); webhook_secret is the HMAC secret the webhook handler signs X-Promptise-Signature with.
  • CLI: promptise validate --allow-missing-env -- report missing environment variables as a warning instead of failing.
  • Runtime triggers: retries and a dead-letter list -- new ProcessConfig.trigger_delivery (TriggerDeliveryConfig, exported from promptise.runtime): max_retries (default 0, since a retry re-runs tools with side effects), exponential retry_backoff / retry_backoff_max, and dead_letter_size. Undeliverable events are listed in AgentProcess.dead_letters with reason, error and attempt count; redeliver_dead_letters() and clear_dead_letters() manage them, and status() reports dead_letter_count, pending_retries and filtered_count. An event that exhausts its retries counts as one consecutive failure.
  • Runtime triggers: cron time zones -- TriggerConfig.cron_timezone / CronTrigger(timezone=...) takes an IANA name and follows daylight-saving time. Schedules without one run in UTC (now documented); the payload carries timezone.
  • Runtime: ContextConfig.conversation_history=False -- turns short-term memory off, so every run sees only its trigger event.
  • Runtime triggers: watch_debounce_seconds for file-watch triggers (default 0.5 s), and filter_expression accepts a callable that takes the TriggerEvent.

  • Runtime: BudgetExceededError and per-call budget checks -- promptise.runtime.BudgetExceededError is the error an agent receives as the tool result when a call is refused by its budget (see Fixed). BudgetState.record_tool_call(name, enforce=True) checks a call before counting it and returns a violation with blocked=True instead of counting it; record_tool_call_sync() is the thread-safe form.

  • Runtime: ProcessConfig.restart_backoff -- seconds before the first automatic restart (default 1.0); doubles per consecutive attempt, capped at 60 s. AgentProcess.status() now reports restart_count and journal_enabled, and restarts emit a process.restarted event.
  • Runtime: RuntimeCoordinator(auth_token=...) and register_node(..., auth_token=...) -- the bearer token sent to nodes, per cluster or per node. Never included in NodeInfo.to_dict() or cluster_status().
  • Runtime: RuntimeTransport(port=0) reports its port -- with port=0 the OS picks a free port and transport.port returns it after start().
  • Runtime: ProcessLifecycle.add_listener() and HealthMonitor.begin_invocation() -- a callback (sync or async) after every state transition; a listener that raises is logged and never undoes the transition. begin_invocation() starts a fresh tool history for stuck/loop detection (the process calls it before each invocation).
  • MCP client: resources and prompts -- MCPClient gains list_resources(), list_resource_templates(), read_resource(uri), list_prompts() and get_prompt(name, arguments) (it had only list_tools and call_tool). They reconnect after a lost session like call_tool. The list methods fetch every page; get_prompt sends non-string arguments as JSON. MCPMultiClient gains the same, routing read_resource by URI (static resources, then the servers' URI templates) and get_prompt by name, discovering on first use, with server= to choose a server, bearer_token= to read as a caller over that caller's session (like call_tool), and resource_to_server, template_to_server and prompt_to_server views.
  • build_agent(expose_resources=True, expose_prompts=True) -- gives the agent list_resources / read_resource and get_prompt tools whose descriptions list what the connected MCP servers publish. They fire the agent's tool callbacks and, like MCP tool calls, send the invoking caller's bearer token to servers with forward_caller_token (the default), so a role-guarded resource or prompt is checked against the user, not the agent.
  • MCP server SDK: catch-all template placeholders -- {path*} (or {+path}) matches the rest of the URI, slashes included (docs://pages/{path*} serves docs://pages/guides/setup); {name} still matches one path segment. Templates without a catch-all are tried first, and parameters are percent-decoded.
  • MCP server SDK: list_changed notifications -- the server advertises listChanged for tools, resources and prompts, and a registration made while serving sends notifications/{tools,resources,prompts}/list_changed to connected clients. server.notify_tools_changed(), notify_resources_changed() and notify_prompts_changed() send them on demand.
  • @prompt(description=...) and Prompt.description -- the description MCPServer.include_prompts() shows to MCP clients (a YAML prompt's description is used too).
  • TestClient.read_resource_contents(uri) returns the MCP contents with their mimeType; read_resource, read_resource_contents and get_prompt take headers=.
  • AutoApprovalClassifier(ask_rules=[...]): "always ask a human" -- a rule type that sends a matching call straight to the fallback handler, even when an allow rule, the read-only check or the LLM classifier would have approved it. Ask rules run after deny rules and before allow rules. Counted in stats.ask_rule_hits; the decision's trace.layer is "ask_rule".
  • ApprovalPolicy(on_decision=...): one audit hook for every decision -- called with (request, decision) before the tool runs, for the handler's decisions, the classifier's, and the ones the gate makes without asking anyone (max_pending, the repeated-denial limit, a timeout, a handler error), and for server-side gates answered through MCP elicitation (metadata["source"] == "mcp_elicitation"). Sync or async; errors are logged and ignored. ApprovalDecision gained decided_by ("reviewer", "classifier" or "gate") and trace (the ClassifierDecisionTrace), and the approval.granted / approval.denied events carry decided_by and classifier_layer.
  • MCP tool annotations reach the approval gate -- Promptise's MCP client now keeps each tool's annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title) on the LangChain tool's metadata, as langchain-mcp-adapters does, and ApprovalRequest.tool_annotations carries them to the handler (and to_dict(), so webhook payloads). The server-side ApprovalGateMiddleware fills it from the tool's @server.tool(read_only_hint=..., destructive_hint=...). The classifier's read-only layer uses them (see Changed).
  • MCP server SDK: @server.tool(cache=False) -- keeps a server-wide CacheMiddleware from caching a tool (writes, things that must always be fresh). Tools annotated destructive_hint=True are never cached. Also on MCPRouter.tool().
  • MCP server SDK: request coalescing in @cached -- identical calls that arrive while the first is still computing share its result (or its error) instead of each calling upstream, so a burst against a cold or expired entry costs one upstream call (5 concurrent cold calls: 5 upstream calls before, 1 now). Keyed on the full, caller-scoped cache key, so callers the cache keeps apart are never coalesced. Per process. @cached(coalesce=False) turns it off.
  • MCP server SDK: ConcurrencyLimitError (CONCURRENCY_LIMIT_EXCEEDED) -- max_concurrent and ConcurrencyLimiter refusals had the same code as rate limits (RATE_LIMIT_EXCEEDED), so an agent could not tell "busy, retry in a moment" from "over quota". Behaviour change: they now use their own code. ConcurrencyLimitError subclasses RateLimitError, so except RateLimitError still catches it.
  • build_agent(context_compaction=...) -- turn compaction off (False: the model always sees the full transcript), change the threshold (an int: tool results before compacting), or pass a ContextCompaction (promptise.engine) with after_tool_results, keep_result_chars, max_tokens (also compact past this many tokens), history_messages and history_chars. PromptNode(compaction=...) sets it for one node and wins over the agent's setting.
  • ConversationFlow(token_budget=...) -- flows ignored token budgets. A flow now takes token_budget (constructor argument or class attribute) and drops blocks lowest-priority first when over budget, like PromptAssembler. Also new: the ConversationFlow.current_phase property and promptise.prompts.flows.FlowSessions, the per-conversation registry the agent uses.
  • MCP server SDK: scope for @cached and CacheMiddleware -- "client" (default: same issuer, tenant and client id), "tenant" (every client of a tenant) or "shared" (everyone; an explicit opt-out for caller-independent data). Exported as CacheScope.
  • MCP server SDK: JWTAuth(audience=..., issuer=...) -- the multi-tenancy docs configured JWTAuth(secret=..., audience=...), which raised TypeError: JWTAuth checked neither aud nor iss, so a token minted for any service sharing the secret was accepted. When set, audience (a string or a list) must appear in the token's aud (a string or a list) and issuer must equal iss; tokens without the claim are rejected. create_token() stamps the configured audience and issuer unless the payload sets them. AsymmetricJWTAuth takes the same two arguments (without audience it keeps PyJWT's behaviour of rejecting tokens that carry aud).
  • MCP server SDK: MCPServer(hide_unauthorized_tools=True) -- tools/list returned every tool to every client, so a tool guarded with HasTenant("acme") advertised its name, description and schema to every tenant. With the flag, tools/list and the docs://manifest resource are filtered per request: the request is authenticated with the server's (or the tool's router's) AuthMiddleware and each tool's guards are evaluated against that identity. Fails closed: unverifiable credentials hide every tool that needs authentication, and a guard that raises hides its tool. Calls are still guarded as before. Off by default. AuthMiddleware.authenticate(ctx) is now public, resource handlers receive the request's headers in ctx.meta (per message, as tool calls do), and TestClient.list_tools() applies the same filter and takes headers=. It filters resources/list, resources/templates/list and prompts/list (and their manifest sections) the same way.
  • Example: examples/mcp/multi_tenant_agent.py -- one agent serving two tenants concurrently over a real HTTP server, audience-checked tokens, caller-scoped caching, and per-tenant tool lists; runs offline.
  • Guardrails: tool-result scanning (indirect prompt injection) -- PromptiseSecurityScanner(scan_tool_results=True) makes build_agent wrap every tool so its result passes check_tool_result() before the model reads it: all heads run (the injection model included), secrets and PII are redacted, and a blocked result is replaced with a [Tool result withheld by guardrails: ...] notice. Off by default. Also available as promptise.guardrails.wrap_tools_with_guardrails().
  • Action and Severity are exported from promptise, as the guardrails docs already imported them.
  • build_agent(code_action=...) -- exec_timeout, max_repairs and max_tool_calls of the code-action pattern are now settable from build_agent as a dict or a promptise.engine.CodeActionConfig (unknown keys raise; using it with another pattern raises).
  • trace_tools covers every tool -- cross-agent tools, sandbox tools and extra_tools now report to trace_tools and the observability collector like MCP tools do.
  • Progress notifications reach Promptise clients and agents -- a server's ProgressReporter.report() calls reached the official MCP client but never a Promptise one: MCPClient.call_tool() passed no progress callback, so it requested no progress. MCPClient.call_tool() and MCPMultiClient.call_tool() take progress_callback= (async (progress, total, message)), MCPToolAdapter takes on_progress= ((tool_name, progress, total, message), sync or async; a failing callback is logged and never fails the call), and build_agent() takes on_tool_progress=. With build_agent(events=...) each notification is also emitted as a tool.progress event, and trace_tools=True prints it. A progress token is sent only when one of these is set. Documented under Receiving progress.
  • MCPQueue: queue_submit lists the job types and their arguments -- its description now names every registered job type with its docstring summary and the JSON Schema of its arguments (injected ProgressReporter / CancellationToken parameters left out), and job_type is an enum of the registered names, so an agent no longer has to guess. priority (and queue_list's status) are enums too; an invalid value is a VALIDATION_ERROR instead of an INTERNAL_ERROR.
  • MCPServer(cancel_grace_period=5.0) and MCPQueue(cancel_grace_period=0.0) -- how long a cancelled handler that takes a CancellationToken gets to stop on its own before its task is cancelled (see Fixed).
  • examples/mcp/queue_server.py -- a runnable queue example: advertised job types, argument validation, progress polling, ownership and cancellation. The queue docs pointed to this file, which did not exist.
  • Adaptive strategy: lesson management -- agent.adaptive_strategy exposes the manager, with list_lessons() (returning AdaptiveLesson), pending_lessons(), approve_lesson(), forget_lesson(), reset() and synthesize(), each scoped to the current or a given caller.
  • MCP server SDK: HTTP health routes -- the HTTP and SSE transports answer GET /health (liveness, 200) and GET /health/ready (readiness from the HealthCheck registered with register_resources(): 200 when every required check passes, 503 otherwise; 200 with no checks when none is registered). A plain GET /mcp is answered 406, so the documented Docker curl -f http://localhost:8080/mcp healthcheck and the Kubernetes httpGet: /mcp probe always failed. Both routes skip the transport auth gate and Host validation (probes carry no credentials and send the pod IP as Host), are no-store, and never include a failing check's exception text — the health://readiness MCP resource keeps it. New: HealthCheck.liveness_report() and readiness_report(include_errors=). Example: examples/mcp/http_server_restart.py.
  • MCP server SDK: MCPServer.asgi_app() -- the server as an ASGI application for uvicorn, gunicorn (-k uvicorn.workers.UvicornWorker) or hypercorn, with the same routes, auth gate, token endpoint, CORS and health probes as run(). Host is validated against the loopback names plus allowed_hosts. stateless=True (Streamable HTTP) serves every request without a session so any worker or replica can answer it, at the cost of elicitation, sampling and per-session state.
  • MCP client: MCPClient(auto_reconnect=True) and session_generation -- see the session-loss fix below; auto_reconnect=False keeps the old behaviour, and session_generation counts the sessions a client has opened.
  • Observability: control over what text is recorded -- ObservabilityConfig(record_tool_io=False) (and PromptiseCallbackHandler(record_tool_io=...)) keeps tool arguments, result previews and tool error messages out of events, leaving the tool name, latency, error type and lengths; on by default. See What text is recorded.
  • Observability: AgentRun and get_current_run() (promptise.observability) expose the invocation being observed — its agent.input entry id and running token and call counts.
  • SemanticCache(cache_tool_turns=..., write_tools=[...], read_only_tools=[...]) -- control which tool turns may be cached and which tools count as writes (* wildcards; write_tools wins). cache.is_write_tool(name, annotations) shows the verdict. The cache reads readOnlyHint from the annotations MCPToolAdapter puts on tool.metadata.
  • FallbackChain.bind_tools() and streaming -- see Fixed. promptise.fallback.is_request_error(exc) tells a rejected request from a provider failure.
  • MCP server SDK: verify_audit_log() and promptise audit verify -- check an AuditMiddleware log file. verify_audit_log(path, key) returns an AuditVerification: ok, the entry count, and each problem as an AuditIssue with its line and kind (modified, inserted, duplicate, reordered, deleted with the number of missing entries, missing_start, malformed, unsigned, wrong_key). A last line cut off by a crash is the warning truncated, not tampering, and continuous and restarts say whether the chain continued across restarts. Several files verify as one chain (a log rotated while the server ran), several keys verify a file that was kept through a key rotation, and anchor= (a last_hash saved earlier) catches entries cut from the end, which a hash chain can't show on its own. promptise audit verify FILE... [--key-env VAR] [--anchor HASH] [--strict] [--json] reads the key from an environment variable (default PROMPTISE_AUDIT_SECRET), never prints it, and exits 1 on tampering, 2 when the file or key can't be read. AuditMiddleware(max_memory_entries=10_000) bounds the in-memory buffer. Documented under Verifying a log file.

Changed

  • Events: webhook signatures cover the raw body and a timestamp (breaking for receivers) -- WebhookSink signed json.dumps(payload, sort_keys=True) while sending a differently serialised body, so verifying the raw request body (what most web frameworks hand you, and what signature guides tell you to do) failed, and with no timestamp a captured request could be replayed forever. Every request now carries X-Promptise-Signature: t=<unix seconds>,v1=<hex>, the HMAC-SHA256 of <t>. followed by the exact body bytes (Stripe-style), plus X-Promptise-Timestamp and an X-Promptise-Delivery id that stays the same across retries. Retries are re-signed, so a late retry is not rejected as stale. The new verify_event_signature(body, signature, secret, tolerance=300) checks it in constant time, rejects signatures older than five minutes and accepts a list of secrets while rotating. A sink's generated secret is readable as sink.secret. Receivers that compare the header to a bare hex HMAC of the sorted-key JSON must switch to verify_event_signature(). AgentEvent.compute_hmac() is unchanged; it does not produce the webhook signature.
  • Cross-agent: broadcast_to_agents is opt-in in build_agent() -- an agent with cross_agents now gets only its ask_agent_<name> tools; pass include_broadcast=True (or include_broadcast: true in a .superagent file) for the fan-out tool. make_cross_agent_tools() still includes it by default. The broadcast tool now takes context like the ask tools.
  • Cross-agent: the model can no longer choose a timeout -- timeout_s is gone from the ask_agent_<name> and broadcast_to_agents arguments; timeouts come from CrossAgent.timeout or delegation_timeout.
  • .superagent files: auth: on an HTTP server is a validation error -- it was accepted but never sent, so the server answered 401; the error says to use bearer_token: or api_key:. HTTPServerSpec(auth=...) emits a FutureWarning that it is ignored, and promptise run/list-tools --http "... auth=..." is rejected (use bearer_token= or api_key=).
  • .superagent files: approval settings that cannot be built are rejected at load -- handler: callback (it needs a Python function), handler: webhook without webhook_url, and webhook_secret with another handler are validation errors, also in promptise validate.
  • .superagent files: stdio servers start in the file's folder -- a relative cwd: and a relative command: with a path separator (./bin/server) are resolved against the file's folder, so promptise agent team/agent.superagent works from any directory. Bare commands (python, npx) still come from PATH.
  • .superagent files: the loader reads .env -- like the CLI, load_superagent_file(), resolve_env_vars() and validate_env_vars() load the nearest .env first (never overriding a set variable; PROMPTISE_NO_DOTENV=1 turns it off).
  • CLI: promptise validate fails on missing environment variables -- it exits 1 (pass --allow-missing-env to only warn, or --no-check-env), and it checks the references and environment variables of every cross-agent file at every depth, not only the first level.
  • SuperAgentConfig.to_build_kwargs() warns when it drops cross_agents -- it cannot build them (each is an agent of its own); the UserWarning points to build_superagent().
  • Runtime: OrchestrationAPI rejects an empty auth_token and treats every loopback address (127.0.0.0/8, ::1, localhost) as loopback; with a token set, requests to unknown paths get 401 instead of 404.
  • Runtime: ProcessConfig, TriggerConfig and TriggerDeliveryConfig reject unknown keys -- a misspelt option (hmac_secrets=..., guardrail=...) raised nothing and was silently ignored; it is now a ValidationError.
  • Runtime triggers: the webhook answers 503 with Retry-After while the process can't take events -- see Fixed. GET /health returns 503 in that case too, and a 500 no longer echoes the exception text.
  • Runtime: conversation_max_messages=0 is documented as unlimited -- the field description said "0 = disabled", but the buffer always kept everything (consistent with build_agent(conversation_max_messages=0)). Use conversation_history=False to turn it off.

  • Runtime: ProcessConfig.journal is off by default -- the default is now JournalConfig(level="none"). Journaling used to do nothing at all (see Fixed); now that it writes, a default of "checkpoint" would make every upgraded process start writing .promptise/journal. Pass journal=JournalConfig(...) (or a journal: section in a manifest) to turn it on; JournalConfig() itself still defaults to level="checkpoint".

  • Runtime: mission evidence lists the current invocation's tool calls -- MissionEvidence.tool_calls comes from the health monitor's tool history, which now starts fresh at each invocation.
  • AutoApprovalClassifier: deny rules are checked before allow rules -- allow rules came first, so a broad allow rule won: allow_rules=[ApprovalRule(user="admin@shop.example")] auto-approved delete_customer even with deny_rules=[ApprovalRule(tool="delete_*")]. The order is now deny rules → ask rules → allow rules → read-only → LLM classifier → fallback, so a deny rule always wins. If an allow rule carved an exception out of a broader deny rule, narrow the deny rule instead.
  • AutoApprovalClassifier: argument_contains matches the JSON of the arguments -- the docstring said "JSON-serialized arguments" but the code searched str(arguments), so "'force': True" matched and '"force": true' didn't. It now searches json.dumps(arguments, sort_keys=True, ensure_ascii=False), as documented. Rules written against the Python repr (single quotes, True/False/None) need updating. Matching stays case-sensitive.
  • AutoApprovalClassifier: the read-only layer no longer goes by name prefix alone -- fetch_and_purge_cache and show_and_delete were auto-approved because they start with fetch_ and show_. A tool is now read-only only when no word of its name is a destructive verb (promptise.DEFAULT_DESTRUCTIVE_VERBS: delete, purge, drop, reset, update, send, ...; words split on _, - and camelCase), its annotations don't say readOnlyHint=False or destructiveHint=True, and it is either annotated readOnlyHint=True or (without one) named with a read-only prefix. New options: destructive_verbs= and use_tool_annotations=; classifier.is_read_only(request) shows the verdict.
  • AutoApprovalClassifier: last_trace is the most recently finished decision -- read decision.trace instead (see Fixed). stats updates no longer go through an asyncio.Lock.
  • MCP server SDK: exception handlers for MCPError subclasses now apply -- a handler registered for a specific MCPError subclass (CircuitOpenError, RateLimitError, ToolError, ...) is now consulted before the error is serialised; previously handlers only ran for non-MCPError exceptions. A catch-all Exception (or MCPError) handler still does not swallow structured errors.
  • Docs: Caching & Performance and Resilience Patterns -- request coalescing, the cache=False opt-out, LRU eviction, sub-second Redis TTLs, CONCURRENCY_LIMIT_EXCEEDED and limiter placement, per-tool timeouts without middleware, what the circuit breaker counts as a failure, the single half-open probe, and the default CIRCUIT_OPEN response.
  • Docs: multi-tenant guidance corrected -- the Multi-Tenancy, Multi-User Identity, Secure Multi-Tenant Platform, Multi-User Systems, Server Specs, Building Agents, Caching & Performance, Auth & Security and MCP Client pages (and the multi-user-identity and caching blog posts) describe per-caller token forwarding, the forward_caller_token opt-out, cache scopes, JWTAuth audience/issuer and hide_unauthorized_tools. The Secure Multi-Tenant Platform guide no longer builds a shared agent with one tenant's token; the Multi-User Systems guide's AsymmetricJWTAuth example uses the real parameter names.
  • Guardrails fail closed by default -- a scanner whose ML or service-backed head cannot run now blocks every scan instead of passing it unchecked (see Security). PromptiseSecurityScanner.default() and guardrails=True need transformers and torch (pip install "promptise[all]"); pass fail_open=True to keep the old behaviour. GLiNER (NERDetector) stays a separate pip install gliner, now stated in the docs.
  • Guardrails docs corrected -- model sizes (injection model ~750 MB, GLiNER ~500 MB), the ScanReport example (PII on input is a passing warning, not a block), and references to scan_input / scan_output, which do not exist (scan_text, check_input, check_output, check_tool_result).
  • Sandbox: fails closed instead of degrading -- build_agent(sandbox=...) without code-action used to print a warning and build the agent without sandbox tools when the sandbox could not start (no docker package, Docker not running, gVisor missing); it now raises RuntimeError naming the cause, like code-action already did. Leave sandbox unset (or None/False) to run without one. Unknown sandbox keys raise a ValidationError with a hint instead of being ignored (network_mode → use network, tools → pick an image, typos get a "did you mean"), in build_agent, SandboxManager, SandboxConfig and .superagent files. backend="gvisor" without runsc now reports that the runtime is not registered (listing the available ones) instead of "ensure Docker is installed and running".
  • Sandbox: the configuration matches what is enforced -- new pids_limit (default 256) is applied to the container. disk_limit is now applied as the size of the writable workspace tmpfs (default "1G", was an unused "10G"), and /tmp and /var/tmp never exceed it. The tools option, and the internal security_opt and cap_drop fields, were never applied and are removed (passing them now raises). The unused DEFAULT_APPARMOR_PROFILE and the check for it are gone, and the docs no longer describe a seccomp whitelist or a custom AppArmor profile: the sandbox uses Docker's default seccomp profile and, on AppArmor hosts, Docker's default AppArmor profile, plus dropped capabilities, no-new-privileges and a read-only root filesystem. HOME defaults to the workspace for the container and every exec.
  • Packaging: promptise[sandbox] extra -- the Docker client (docker>=7.0.0) is now installable on its own with pip install "promptise[sandbox]" (still included in promptise[all]); the error for a missing client says so.
  • Docs: observe=True writes an HTML report -- the build_agent docstring and Adaptive Strategy now say that observe=True alone writes a report file to ./reports at shutdown, and that adaptive strategy no longer needs observability.
  • Reasoning Graph: structured outputs are dicts -- with a Pydantic output_schema, result.output and state.context[output_key] now hold model.model_dump() rather than the model instance. Code that read attributes (state.context["verdict"].passes) reads keys (["passes"]), or re-validates with Verdict.model_validate(...).
  • Reasoning Graph: PromptNode default max_iterations (10) now applies -- a custom PromptNode in a long tool loop stops after 10 model calls instead of the engine's 25. Raise it per node (max_iterations=25) where a deep tool loop is expected; the prebuilt patterns set their own budgets.
  • ObservabilityConfig.record_prompts defaults to None -- "follow the level": prompt and response text is recorded at FULL and not below. True still records it at any level; False now also keeps it out at FULL. The .superagent observability.record_prompts field follows the same rule, and an observability: section in a .superagent file is now applied (build_agent() used to replace a dict observe= with the defaults).
  • Mem0Provider.search() raises on errors instead of logging and returning [], so an incompatible release or an unreachable store is visible; the agent still catches it and answers without memory context.
  • PromptiseAgent.shutdown() closes transporters after flushing them (closes the NDJSON stream and log file handles, ends and exports open spans).
  • Top-level chain errors are recorded as agent.error (not tool.error), and a sub-chain error is no longer counted twice.
  • Semantic cache: fewer requests are cached by default -- follow-ups (requests with earlier turns) and turns that called tools are no longer cached unless you pass cache_multi_turn=True / cache_tool_turns=True; scope="per_session" needs a user_id; build_agent(cache=...) fails when the cache's packages are missing. See Security and Fixed for why.

Fixed

  • Events: an agent's events disagreed on agent_id -- invocation.* events carried the model name even when observer_agent_id was set, and every other agent event (tool.*, guardrail.*, approval.*, cache.*) carried none. All events an agent emits now share one agent_id: observer_agent_id, else the identity's agent_id (or IdP subject); agents run by an AgentProcess use the process name; otherwise the model name, as before. The model is also in metadata["model"].
  • Events: tool.error never fired for failing MCP tools -- an MCP tool that fails returns a normal result with an error in it (a Promptise server's ToolError arrives as {"error": {"code", "message", "retryable"}}), so no exception was seen and no event was emitted; tool.error only fired for raising extra_tools, only with observe=True, and with tool_name: "unknown". Error results (isError, the ToolError envelope, a ToolMessage with status="error") and raised exceptions now emit tool.error with the real tool name, error, error_type, duration_ms, and code/retryable when the tool reports them. A domain answer such as {"error": "No invoice X"} (no code) is not treated as a failure.
  • Events: tool.error and tool.slow required observe=True -- both were emitted by the observability callback handler, which only exists with observability on (undocumented). They now come from a handler attached to every invocation of an agent built with events=, for ainvoke(), chat() and the streaming methods, without duplicates when observability is on. The tool.slow threshold is configurable: EventNotifier(slow_tool_threshold=5.0) (seconds; None turns tool.slow off); the event now includes threshold_ms.
  • Events: a process's last events could still be lost -- a startup failure queued process.failed before anything had started the notifier, and stopping a process that had failed never drained it, so that process.failed was lost when the program exited. The process now starts the notifier before startup can fail, and stop() drains a notifier the process owns whether or not it failed; a notifier shared through an AgentRuntime is still drained by stop_all().
  • Events: one slow sink delayed every other sink, and shutdown dropped events silently -- the notifier delivered each event to its sinks one after another, so a webhook retrying against a receiver that was down (about 7 s of backoff with the defaults) held up every later sink and event, and stop() cancelled whatever was left after 5 s without a word (with the receiver down, the fourth attempt and the CallbackSink never ran). Each sink now has its own queue and delivery task, so other sinks get every event at once and order is kept per sink. stop() waits up to EventNotifier(shutdown_timeout=10.0) (or stop(timeout=...)), then logs every dropped event with its sink and type and counts it in notifier.dropped_count; a full queue now drops for that sink only. New: notifier.flush(timeout=), notifier.is_running, and sinks with a close() are closed on stop. emit_sync() is safe from other threads and starts the notifier inside a running loop; a notifier left running by an earlier asyncio.run() recovers in the next.
  • Events: WebhookSink refused every local or private URL with advice that did not exist -- the error suggested a "base_url override" that WebhookSink does not have. Pass allow_private_networks=True to deliver to localhost or a private network (also allow_private_networks: true per sink in .superagent files); the error now says so. URLs that are not http(s) with a host are rejected either way.
  • Events: thin payloads and a redaction gap -- session_id and metadata were always empty, guardrail.blocked did not say why, and approval.requested did not include the arguments under review. session_id is now the chat() session (else caller.metadata["session_id"]); metadata carries model and an invocation_id shared by every event of one run, plus process_name, process_id and trigger_type in the runtime; guardrail.blocked has reason and findings (detector, category, severity, description — never the matched text); approval.requested has arguments as the policy redacts them. WebhookSink redaction covered data only, so a user_id that was an email address was sent in clear; it now covers the whole payload (redact_sensitive=False turns it off). emit_event() fills agent_id, session_id and metadata from the running invocation and takes a user_id.
  • Agents with an identity stopped working once the credential expired -- build_agent(identity=...) fetched the identity's credential once and sent it on every request for the agent's lifetime. After it expired the server answered 401, the MCP SDK tore the transport down, and the tool call waiting for its response never returned. The credential is now requested for each server's audience on every request, renewed before it expires (the session is reopened with it), refreshed once on 401, and a call the server still rejects fails at once with MCPConnectionRejectedError (mid_session=True) instead of hanging.
  • MCP client: a call hung when the server rejected an expired static bearer_token -- the call now fails at once with MCPConnectionRejectedError(mid_session=True), whose message suggests bearer_token_provider; a session that ends while a call is in flight fails that call instead of leaving it waiting. MCPMultiClient names the server in the error and keeps routing the server's tools.
  • Identity: a credential living less than twice the refresh buffer (two minutes) was re-acquired on every request -- the buffer is now capped at half the credential's lifetime. Projected-token files and OIDC tokens, whose audience is fixed, are cached once instead of once per requested audience.
  • Identity: AgentIdentity.auto() ignored $PROMPTISE_IDENTITY_TOKEN_FILE on AWS -- the file that selects the projected-token mode of from_aws was not an AWS marker, so a pod with only that variable failed detection. It is now detected (projected mode); IRSA's $AWS_WEB_IDENTITY_TOKEN_FILE still selects STS mode, and its sts.amazonaws.com token is never presented.
  • JwksAuth: an IdP outage failed every request once the cached keys aged out -- the cached keys now keep verifying while the JWKS endpoint is unreachable (retried every 30 seconds), and a token signed with a newly rotated key is accepted on first use.

  • Cross-agent: runaway delegation -- nothing bounded delegation at run time, so an agent that kept delegating (to itself through a peer, or around a cycle) made well over a thousand model calls before stopping. Delegation is now limited by max_delegation_depth and loop detection (see Added).

  • CLI: promptise agent teams with stdio servers failed with "Not connected" -- cross-agents were built in one asyncio.run and used in another, so their stdio MCP sessions were dead; cross-agents of cross-agents were ignored, and a cross-agent got only its file's servers, model, instructions and trace setting. The command now builds the whole team with build_superagent() in the chat's event loop and shuts it down there.
  • CLI: promptise agent with approval.handler: queue could not be answered -- nobody read the queue, so every gated call waited for the timeout. At a terminal each request is now asked as a y/N question; with piped input the CLI says that requests will time out. A stdio server that fails to start is reported in one line instead of a traceback.
  • Cross-agent: trace_tools=True and observability did not show delegation -- ask_agent_<name> and broadcast_to_agents calls now print and record like any other tool call.
  • Runtime triggers: filter_expression was never evaluated -- events that should have been filtered out still ran the agent (and cost an LLM call). It is now applied to every trigger type. The string form is a small expression language that is parsed with ast and run by a whitelist interpreter, never eval: lookups (payload['issue']['number'], payload.issue.number, bare payload keys), comparisons, and/or/not, literals, and a few functions and string methods. Anything else is rejected when the TriggerConfig is created. The webhook answers 200 {"status": "ignored"} for events that don't match.
  • Runtime triggers: file watch ignored watch_events, and one write ran the agent twice -- the factory never passed watch_events (or a debounce) to FileWatchTrigger, so a trigger for ["deleted"] fired on new and changed files. Debouncing was keyed by path and event type, so the created + modified pair that one write produces ran the agent twice. Events for the same path are now merged within the debounce window into one event, whose event_type is the net change (created, modified, deleted or moved) and whose event_types lists the raw events. watch_events is checked against that merged type, and unknown event names are rejected.
  • Runtime triggers: a failed process kept answering 202 while events piled up -- after max_consecutive_failures the process went to FAILED but its webhook kept accepting events into a queue that nothing consumed. The webhook now answers 503 with Retry-After while the process is failed, stopping or its queue is full, so senders retry later. Events still queued at the failure are dead-lettered. Listener tasks wait for queue space instead of dropping cron and file events, and events that arrive while the process is still starting are no longer dropped.
  • Runtime triggers: a bad cron expression only failed at run time -- "every day at 9" was accepted, the process started, and its listener logged a traceback every second forever. Cron expressions are now validated when the TriggerConfig or CronTrigger is created (5 fields, or 6 with croniter's trailing seconds field). A listener whose trigger keeps failing now backs off exponentially up to 60 s, logging the traceback only once.
  • Runtime: ProcessConfig silently dropped guardrails, observe and cache -- _build_agent() looked for them, but the model didn't declare them, so they were discarded (as were optimize_tools, adaptive and max_invocation_time). They are now real fields and reach build_agent().
  • Runtime triggers: a non-ASCII signature header crashed the webhook handler -- hmac.compare_digest raised TypeError on it, so the sender got a 500 instead of a 401.
  • Runtime trigger docs corrected -- the webhook binds to 127.0.0.1 by default (not 0.0.0.0) and has an HMAC check; max_consecutive_failures defaults to 3 (not 5); sub-minute cron works through the seconds field; croniter and watchdog ship with promptise; schedules are UTC unless a time zone is set.

  • Runtime: budget limits were only checked after the run -- tool calls were counted as they happened, but on_exceeded ran only after ainvoke() returned, so an agent with max_irreversible_per_run=1 could send ten emails in one run, and max_tool_calls_per_run / max_cost_per_run capped nothing inside a run. A budget guard now runs before every tool call (sync and async tools, parallel calls one by one): a call that would exceed a tool-call, cost or irreversible limit, or any call after max_llm_turns_per_run is exceeded, is refused before the tool runs and not counted, and the agent gets BudgetExceededError as the tool result telling it to stop and answer. on_exceeded still applies after the run.

  • Runtime: restart_policy and max_restarts did nothing -- a process that reached max_consecutive_failures stayed FAILED whatever the policy. "on_failure" and "always" now tear the process down and start it again with backoff, up to max_restarts consecutive attempts (the count resets after a successful invocation, and a restarted process starts a fresh failure streak). "always" also recycles a process at max_lifetime instead of leaving it stopped. An explicit stop() cancels a pending restart; a failure of your own start() is raised, not retried. process.failed is now emitted for any FAILED transition, not only a failed start.
  • Runtime: ProcessConfig.journal was never written -- the docs said every transition, trigger, invocation result and checkpoint is journaled, but AgentProcess never created a journal, so promptise runtime logs and ReplayEngine had nothing to read. A process with a journal level now records state transitions, invocation results and errors, restarts and a checkpoint (context state, lifecycle state, conversation, budget and mission state) after every invocation; level="full" adds trigger events, invocation starts and every tool call and result. Entries are filed under the process name, secret-access and mission entries land in the same journal, and journal failures are logged without stopping the process.
  • Runtime: replay applied stale entries over the newest checkpoint -- ReplayEngine.recover() replayed everything after the first checkpoint entry, so with several checkpoints old context_update values overwrote the latest snapshot. It now replays only the entries after the last checkpoint, and works on a copy, so recovering no longer modifies the checkpoint stored in an InMemoryJournal.
  • Runtime: tool results counted as empty agent replies -- the health monitor recorded every tool output as an agent response, so a tool returning [] three times raised an empty_response anomaly (and could pause or stop the process). Empty-response detection now looks at the agent's final reply only.
  • Runtime: scheduled agents were reported as stuck -- tool history carried over between invocations, so an agent that checks the same queue once per cron tick raised a stuck anomaly on its third run. Stuck and loop detection now look within one invocation.
  • Runtime: health.recovered fired after every success -- once any anomaly had occurred, every later successful invocation emitted health.recovered. It is now emitted once, on the first success after an anomaly.
  • Runtime: the conversation buffer stored the wrong reply -- the process kept the first assistant message of the result, which is an earlier reply echoed from the conversation history or a tool-call request, not this run's answer. It now stores the agent's final reply.
  • Runtime: process.stopped events were lost -- the agent's shutdown stopped the event notifier, which the process does not own (an AgentRuntime shares one with every process), before process.stopped was emitted, and stopped delivery for the other processes too. Stopping a process leaves the notifier running; a standalone process drains it after process.stopped, and AgentRuntime.stop_all() drains it once every process has stopped.
  • Runtime: RuntimeCoordinator reported failed operations as successes -- start_process_on_node(), stop_process_on_node() and inject_event_on_node() returned the node's error body ({"error": "Unauthorized"}, 404, 500) as if the call had worked. They now raise RuntimeError naming the node and status.
  • Runtime: RuntimeCoordinator ignored node_timeout -- one failed health check marked a node unhealthy. A healthy node now stays healthy until it has failed checks for node_timeout seconds (the result marks the miss with missed_check). A failing health round no longer ends the background monitor, and a cancelled node check marks the node unhealthy instead of crashing the round.
  • MCP server SDK: resources lost their MIME type and data -- read_resource returned str(result): a dict arrived as a Python repr, bytes as "b'...'", and the declared mime_type was dropped. Dicts, lists and Pydantic models are now JSON, bytes are sent as a base64 blob (BlobResourceContents), and each content carries the declared MIME type. Without mime_type=, it is inferred from the return annotation (application/json for dict / list / a model, application/octet-stream for bytes, else text/plain). A handler can also return ReadResourceContents items for several contents. An unknown URI is the MCP "resource not found" error (-32002).
  • MCP server SDK: prompts returning a list failed -- a handler returning a list of PromptMessage failed Pydantic validation. A prompt may now return a str, a PromptMessage, a {"role", "content"} dict, an MCP content block, a list of any of those, or a GetPromptResult.
  • MCP server SDK: prompt arguments and template parameters were always strings -- they are now converted to the handler's type hints ("3" → 3, "true" → True, JSON text → a list), with a validation error for a value that does not fit. A template whose {placeholder} names no handler parameter is refused at registration.
  • MCPServer.include_prompts() -- multi-line docstring templates kept the code's indentation and sent it to the model; they are dedented now (PromptBuilder templates are kept as written). Argument descriptions were the argument's own name; they now come from a YAML prompt's arguments, an Annotated[..., Field(description=...)] hint, or describe the placeholder, type and default (Fills {max_words} in the prompt (int, default 50).). String arguments are converted to the parameters' types before rendering.
  • TestClient.list_resources() omitted the docs://manifest resource the live server serves, and the docs named it manifest://server. The manifest now also lists each resource's and prompt's auth_required, roles, guards, rate_limit and timeout. mount() copies resources and prompts instead of sharing the child's definitions, and skips the child's manifest.
  • Streaming: the answer after a tool call never streamed, and every streamed step cost an extra model call -- PromptNode.stream() was a simplified copy of execute() that ended without a result, so PromptGraphEngine.astream_events() ran the node again with execute(). After a tool call that second, unstreamed call produced the answer: astream_with_tools() sent no token events and DoneEvent.full_response was "". An answer without tools streamed, then a second paid model call ran and was discarded (about 3 s before done with gpt-5-mini), and its message was appended to the conversation as well. PromptNode.stream() now runs the same pipeline as execute() (system prompt, tool schemas, context scope, hooks, guards, postprocessor) with its one model call streamed, and the engine never re-runs a node: a node whose stream() ends without a result fails the run. One model call per step; the answer after a tool streams token by token; full_response is the final answer -- the text of the run's last model call, the same text ainvoke() returns (text a model writes before calling a tool still streams as tokens and is in cumulative_text). Content-block chunks (Anthropic, Gemini) are read for their text instead of failing.
  • Streaming: tool events had the wrong duration and index, and tools ran one after another -- the engine gave every event a new run_id, so a tool_end never found its tool_start: duration_ms was measured from the start of the run and tool_index was always 0. Events now keep the node's run_id, duration_ms is the call's own run time, and a tool_end has the tool_index of its tool_start. Tool calls in one step run in parallel while streaming, as they always did in ainvoke(): every tool_start first, then each tool_end as that call finishes.
  • Tool errors were reported as successes -- a tool raising ToolError produced tool_end with success: true, because MCPServer answered failed calls (ToolError and other MCPErrors, mapped and unexpected exceptions, unknown tools) as ordinary results without isError. The server now sets isError: true on them (the JSON error text is unchanged). The MCP tool adapter raises MCPToolError for such a result (see the adaptive strategy entry), and the engine treats a tool's ToolException as an error the tool reported: the model sees the server's error text, the call's ToolMessage has status="error", it counts in NodeResult.tool_calls_failed, and the context ledger never serves it as a fact. In astream_with_tools() a failed call is success: false with the server's message as tool_summary ("Tool call failed" when the tool raised anything else -- the exception text is not streamed). The engine invokes tools as tool calls, so a tool that handles its own errors (handle_tool_error) is reported as failed too, and observability records a tool's ToolMessage result by its content.
  • agent.astream() raised AttributeError: 'PromptGraphEngine' object has no attribute 'astream' -- PromptGraphEngine.astream() now yields {"messages": [...]} after every node (LangGraph's stream_mode="values"); the last chunk is what ainvoke() returns. PromptiseAgent.astream() runs input guardrails first (a violation raises), checks an answer with the output guardrails before yielding its chunk, inherits the ambient CallerContext like ainvoke(), and stores the exchange in memory. astream_with_tools() now inherits the ambient CallerContext too, instead of running with no caller when none is passed (a peer agent streamed during another agent's run lost the user's identity).
  • Engine: streamed runs skipped node hooks and graph mutations -- ainvoke(), astream() and astream_events() now drive one traversal loop, so a streamed run applies pre_node / post_node hooks, runtime graph mutations and LLM graph actions, emits on_node_error for every failed node, and records last_report, exactly as ainvoke() does. The node's own on_node_start / on_node_end are no longer forwarded next to the engine's.
  • Adaptive strategy: streamed runs did not learn -- astream() and astream_with_tools() neither recorded failed tool calls nor injected lessons. Both now do, as ainvoke() does; failures are recorded also when the run fails or the consumer stops reading.
  • Docs: the streaming FastAPI example could not work and re-ran questions -- Streaming showed a POST endpoint consumed with EventSource, which only sends GET, and never closed the EventSource: browsers reconnect about 3 seconds after the response ends and ask the question again. The example is now a GET endpoint with the question in the query string, a frontend that closes the source on done, error and onerror, and a fetch() reader for POST. The page also documents what a streamed run does (one model call per step, parallel tools, tool_index, failures), tool_error_summary(), and agent.astream(). New example: examples/mcp/stream_tool_calls.py.
  • MCP server SDK: CacheMiddleware ignored the tool's arguments -- the key read ctx.state["arguments"], which the server never sets (it sets _tool_arguments), so every call to a tool returned the first cached answer: a caller asking for SKU-9 got the SKU-7 result cached for another tenant. Entries are now keyed on the server, the tool, the validated arguments and the caller (the same fix as the tenant-isolation change, which adds scope=). The docstring's opt-out (tdef.cache = False) did not exist: see @server.tool(cache=False) below.
  • MCP server SDK: @cached on a tool with ctx: RequestContext never cached -- the context, with a random request id, was part of the key. Injected parameters (RequestContext, Depends(...) values, BackgroundTasks, ...) are no longer part of the key, and the caller is (client by default; scope="tenant" / "shared" widen it).
  • MCP server SDK: max_concurrent refusals opened the circuit breaker -- the per-tool limiter that the server adds for @server.tool(max_concurrent=...) was appended after (inside) CircuitBreakerMiddleware, so "at capacity" refusals counted as failures: 8 parallel calls to a max_concurrent=2 tool opened the circuit and paused the tool for everyone. The limiter now goes just in front of the first breaker, and the breaker no longer counts capacity or rate-limit refusals in any order.
  • MCP server SDK: an open circuit reached the model as a non-retryable INTERNAL_ERROR -- unless the server registered a handler, CircuitOpenError became the generic "An internal error occurred." (and under TestClient, the raw exception text). It is now an MCPError that serialises as code CIRCUIT_OPEN, retryable: true, with details.retry_after_seconds and a suggestion naming the wait. @server.exception_handler(CircuitOpenError) handlers still apply.
  • MCP server SDK: the circuit breaker counted every error -- a ToolError for bad input counted as a failure, so three bad lookups paused the tool for every caller. By default only unexpected exceptions and retryable MCPErrors (such as TIMEOUT) count; non-retryable ToolErrors, auth / access denials, validation errors, rate-limit and concurrency refusals, approval denials and CircuitOpenError don't, and leave the failure streak unchanged. CircuitBreakerMiddleware(is_failure=...) overrides the rule; the default is exported as is_upstream_failure.
  • MCP server SDK: half-open let every waiting call through -- the docs promise one probe call, but after recovery_timeout all concurrent calls reached the recovering tool (5 of 5 in the repro). Exactly one probe now runs; calls arriving meanwhile get CIRCUIT_OPEN. A probe that ends without a verdict (bad input, cancellation) leaves the circuit half-open for the next call. A failure re-opens the circuit from the time it failed, not from when the call started.
  • MCP server SDK: @server.tool(timeout=...) did nothing without TimeoutMiddleware -- a 0.2 s limit let a 1 s call finish. The server and TestClient now enforce a tool's declared timeout themselves, around the tool's guards and handler (not middleware in front of it, such as an approval gate). async tools only: a synchronous handler cannot be interrupted.
  • MCP server SDK: TestClient ignored max_concurrent and leaked exception text -- it now adds the per-tool limiter like the live server, and an unhandled exception returns the same generic INTERNAL_ERROR message as the live server instead of str(exc).
  • MCP server SDK: InMemoryCache(max_size=...) was documented as LRU but evicted the oldest insert -- a hot entry was evicted while cold ones survived. Reads now refresh an entry, the least recently used one is evicted, and overwriting an existing key at capacity no longer evicts another entry.
  • MCP server SDK: RedisCache rejected a TTL under one second -- setex(int(ttl)) sent 0, which Redis refuses (invalid expire time). TTLs are now sent in milliseconds (SET ... PX); a non-positive TTL is not stored.
  • MCP server SDK: rate-limit suggestion said "Wait 0 seconds" -- waits under 0.5 s were rounded down. The suggestion now rounds up ("Wait 1 second before retrying.").
  • MCP server SDK: the timeout suggestion told the model to "Try with simpler input" -- unhelpful when an upstream service hangs. It now suggests retrying later or continuing without the tool.
  • promptise models check said "Usable." for a local model that was not running -- the check only looks at configuration, and Ollama needs none, so promptise models check ollama:llama3.1 printed Usable. and exited 0 on a machine without Ollama. The verdict is now Configuration OK, with a note that nothing was called and that --ping makes a real call. For a keyless provider (Ollama) or an endpoint on this machine (OPENAI_BASE_URL=http://localhost:8000/v1), the check also tries a TCP connection (1 s timeout, nothing sent) and, when nothing answers, prints Not reachable. ... nothing is listening at http://localhost:11434 — is Ollama running? (ollama serve) ... and exits 1.
  • promptise models check printed the route as a template -- route: OpenAI-compatible endpoint {endpoint}/v1 for Ollama. The route now shows the URL requests go to: http://localhost:11434/v1 by default or the normalised OLLAMA_HOST, and Bedrock's and Vertex AI's region and project filled in from the environment (a value that is still missing stays a {placeholder} next to its MISSING line). The CLI and resolve_model() build the URL with the same code.
  • A failed --ping showed only the provider's error -- OpenAIConnectionError: Connection error. with no hint. The error is now followed by a → line saying what it most likely means and what to do: nothing listening (with ollama serve / OLLAMA_HOST for Ollama, or the variable the address came from), a timeout, a rejected key (naming the variable and where to get a new one), a model the server does not have (ollama pull <model>; the Azure deployment Name column; the provider's model list), missing model access (Bedrock), an account out of credits, a rate limit, or a provider outage.
  • Docs: Claude model IDs written with dots -- the quick-recommendation table on the Best LLMs for agents page, the FAQ and the core overview used anthropic:claude-sonnet-4.6, anthropic:claude-opus-4.6 and anthropic:claude-sonnet-4.5, which Anthropic's API rejects. They now use the real IDs (claude-sonnet-4-6, claude-opus-4-6, claude-sonnet-4-5), as the model tables on the same page already did.
  • Approval: the gate's own denials left no record, and classifier denials used up the retry limit -- once max_retries_after_deny (3) was reached, or max_pending requests were waiting, calls were denied before the handler ran, so an audit log written from the handler had no entry for them. Denials by classifier rules also counted towards the limit: after a deny rule refused three over-the-limit refunds, a $5 refund the allow rule covers came back "permanently denied". Every decision now reaches ApprovalPolicy(on_decision=...) and emits approval.denied / approval.granted with decided_by. Only a reviewer's denials (decided_by="reviewer", including the classifier's fallback) and timeouts count towards the limit, and only a reviewer's approval resets it; classifier decisions don't touch it. The count stays per deny_scope (default: same user and session).
  • AutoApprovalClassifier: concurrent decisions overwrote each other's trace -- last_trace was one shared attribute, so with several gated calls in flight an escalated call waiting on a human was logged as fallback with an empty reason after a read-only call finished in between. Each decision now carries its own ClassifierDecisionTrace as ApprovalDecision.trace (on a copy of the fallback's decision; the handler's object isn't mutated).
  • Context compaction dropped the user's question -- once a tool loop passed 6 tool results (call 7 on, with the default ReAct agent's context_scope="auto"), the compacted view kept only the first input message that was a HumanMessage object. A question passed as a {"role": "user", ...} dict, the form the docs and quickstart use, disappeared, and the agent asked what to do or answered partially. Input messages are now converted to LangChain messages when the graph state is created (dicts, tuples and strings alike), and compaction always sends the current user question: the last user message of the input.
  • Compaction answered the session's first question in a chat -- with chat history in the input (as agent.chat() builds it), the compacted view showed the first question of the session instead of the current one. Earlier turns now become a short "Earlier in this conversation" note (the last 6 messages, 400 characters each), and the current question follows it.
  • Compaction dropped system and runtime-injected messages -- system messages in the input (your own, and runtime ones such as [Context State], mission and budget) vanished once a loop compacted; they are now pinned on every call. A tool-loop re-entry no longer overwrites the input's second system message with the node's prompt, and GraphState.trim_messages() keeps the current question and never starts the window on a tool result whose call was cut.
  • The ledger did not shrink anything -- it repeated every tool result in full, and the latest results appeared twice (in the latest exchange and in the ledger), so a parallel batch doubled the final call's tokens. The ledger now lists only results older than the latest exchange, once per (tool, args) (last value wins), cuts a result longer than 2,000 characters to an excerpt that names the call so the model can fetch it again (served from cache), and, with a token budget, shrinks the oldest entries to a bare reference until the view fits. A cache-served repeat call now returns the latest result for its (tool, args).
  • ContextEngine layers never reached the model -- build_agent called engine.clear_all() on every call, so add_layer() content was lost even with required=True; the instructions were sent twice; and the question was turned back into a dict. The engine is no longer cleared: the agent passes this call's content to assemble() as overrides, your layers are sent on every call, assembly never changes the registered layers, the instructions and tool definitions count toward the budget without being sent twice, input system messages pass through, and the question is sent as given. What the budget leaves after the instructions and tool definitions becomes the token budget for compacting the tool loop, so the engine bounds every model call, not only the first. An engine without some built-in layers (auto_register_builtins=False) no longer fails the call.
  • Wrong context window for GPT-5 -- gpt-5, gpt-5-mini and gpt-5-nano were assumed to have 128k tokens; OpenAI lists 400k (272k input). The table now has 400k with the budget capped at the 272k input limit, plus GPT-4.1, o4-mini and Gemini 2.5. With build_agent, the window comes from the chat model's profile metadata when it has one; an explicit model_context_window still wins.
  • Custom graphs -- an inject_tools=True node in a graph passed as agent_pattern now gets the agent's MCP and extra_tools, and a single-node graph without edges runs once instead of being wrapped in a planner that could re-pick it.
  • Conversation flows: the first message never reached the flow -- the agent called flow.start() on the first turn without the user's message and passed only later messages to next_turn(), so a flow that decides its phase from the conversation stayed in its initial phase for the whole first turn (and for good, if the deciding detail was in the first message). ConversationFlow.start() takes an optional user_message, recorded in the history before the initial handler runs (turn 0), and the agent feeds every user message to the flow.
  • Conversation flows: the model got two system messages and the generic default prompt -- the flow's prompt and the agent's own system message (instructions plus the tool list) went in as two system messages, and with no instructions (or "") the second one was the generic "You are a capable deep agent…" prompt. The model now gets one system message: the flow's prompt, then instructions, then the tool list. With a flow and no instructions, the default prompt is no longer added. This holds across tool-loop iterations and for context-scoped nodes, which previously dropped the flow's prompt.
  • Conversation flows with a semantic cache -- a cache hit returned before the flow ran, so the conversation's flow missed that message, and the cache key ignored the flow's prompt, so a reply cached in one phase could be served in another. The flow now advances before the cache lookup, and its prompt is part of the cache's instruction hash.
  • Prompt used as agent instructions: guards never ran -- build_agent(instructions=my_prompt) rendered the prompt with render_async(), which skips guards, so @guard(...) and with_guards(...) had no effect on an agent. Input guards now check each user message (a guard may rewrite it or raise GuardError), and output guards check each reply, on ainvoke(), invoke(), chat() and astream_with_tools() (which yields an ErrorEvent when a guard rejects). For a PromptSuite, every prompt's guards apply.
  • Prompt Inspector: flows and agents recorded nothing; @prompt traces were incomplete -- ConversationFlow takes an inspector (constructor argument or class attribute) and records a trace, with flow_phase and flow_turn, on every start(), next_turn() and transition(); per-conversation copies share it. Prompt.render_async() records a trace, so a prompt used as agent instructions is traced on every turn. A @prompt call now records a trace even without blocks, with input_text set to the full text sent to the model (not only the blocks), the output, the latency and guard results, as the Inspector page documents.
  • build_agent(guardrails=True) crashed on the first message -- True was stored as the guard, so the first ainvoke() raised AttributeError: 'bool' object has no attribute 'check_input'. True now means PromptiseSecurityScanner.default() and False means no guardrails.
  • Guardrails: overlapping redactions deleted text -- a connection string such as postgres://user:pass@host/db also matches the email pattern, and replacing both spans one after the other cut into the text that followed. Overlapping findings are now merged into one span, labelled by the longest finding and then the most specific detector (custom rule, credential, PII, NER).
  • Guardrails: blood_type redacted order numbers and grades -- \b(?:A|B|AB|O)[+-]\b turned "order A-1001" into "[MEDICAL]1001". The pattern now needs a "blood type" / "blood group" keyword and does not match when a digit or letter follows the sign.
  • Guardrails: input redaction had no effect -- the agent ignored the value check_input returned. The returned text now replaces the user's message (in a copy; the caller's input is not mutated) in ainvoke, astream, astream_with_tools and chat, and chat stores the rewritten message in the conversation history. New PromptiseSecurityScanner(redact_input=True) applies the PII and credential actions to input, so PII is redacted before the model sees it (default: input PII stays a warning).
  • Agent: plain astream() skipped the input guardrail, and astream_with_tools() served the unredacted response when the output guard raised something other than GuardrailViolation; it now ends the stream with an error event, as ainvoke() raises.
  • Sandbox: write_file and the sandbox_write_file tool always failed -- they used Docker's archive API, which cannot write to a container with a read-only root filesystem ("rootfs is marked read-only"). Files are now written from inside the container in base64 slices (safe for any content, any size), to a temporary name that is renamed into place, creating missing parent directories.
  • Sandbox: install_package failed on the read-only root filesystem -- pip fell back to a user install under the read-only /root/.local. HOME now points at the workspace, so pip installs to /workspace/.local; npm installs into /workspace/node_modules (no more -g), cargo and go into the workspace too. When an install fails with network="none", the result explains that the sandbox has no network.
  • Sandbox: allow_sudo=True had no effect -- it removed SETUID/SETGID from a drop list that names CAP_SETUID/CAP_SETGID. It now keeps exactly those two capabilities.
  • Code-action: earlier turns and system context were ignored -- the program-writing prompt only saw the latest question, dropping conversation history, memory context and conversation-flow system prompts. They are now included.
  • MCPQueue: a cancelled job kept running and then reported completed -- queue_cancel only set the job's CancellationToken, so a handler that never called cancel.check() ran to the end, and its result overwrote the cancelled status. Cancelling a running job now sets the token and cancels the job's task (immediately by default, or after cancel_grace_period); whatever the handler returns afterwards is discarded and the job stays cancelled with no result. With a shared backend, a job cancelled from another process is not overwritten by the worker that ran it either. queue.stop() cancels jobs still running and marks them cancelled ("Queue stopped before the job finished") instead of leaving them running forever.
  • MCPQueue: wrong job arguments were retried as if transient -- arguments were only checked when a worker called the handler, so a missing or unknown argument failed with a TypeError, was retried max_retries times with backoff, and then failed. Arguments are now validated against the handler's signature when the job is submitted (coerced like tool arguments; unknown arguments rejected unless the handler takes **kwargs), and bad ones are refused at once with a non-retryable INVALID_JOB_ARGUMENTS error that includes the expected schema. No job is created.
  • MCPQueue: retries held a worker for the whole backoff -- the worker slept through each backoff, so with max_workers=1 one failing job stalled the queue. A failed attempt now schedules the retry and frees the worker at once. queue.stop() puts jobs waiting on a backoff back on the queue, and cancelling such a job drops its retry.
  • MCPQueue: error stayed set after a retry succeeded -- a job that completed on its second attempt still reported "Attempt 1 failed: ...". A successful attempt clears it.
  • MCPQueue: queue_status promised a "progress percentage" -- progress is a fraction from 0.0 to 1.0, and the tool description now says so.
  • BackgroundTasks delayed the response -- the server awaited the background tasks before returning the tool result, so a client calling a tool whose background task took 3 seconds waited 3 seconds, contrary to the docs. Background tasks now run in their own task after the response is sent; the server waits for any still running on graceful shutdown. TestClient still runs them before call_tool returns, so tests can assert on their effects.
  • CancellationToken was never set on tools -- nothing called .cancel(): when a client sent notifications/cancelled, the MCP SDK cancelled the handler's task and the token stayed unset, so cancel.check() and cancel.wait() never fired. The token is now set when the client cancels the call, and the handler gets cancel_grace_period (default 5 seconds) to stop on its own before its task is cancelled; the client is answered at once either way. Tools without a token are cancelled immediately, as before.
  • Queue docs -- the testing example used async with TestClient(...) (TestClient is not a context manager), read resp["job_id"] (call_tool returns MCP content; parse resp[0].text), and never started the workers (startup hooks don't run under TestClient; call queue.start()). The docs imported the internal _JobProgressReporter; jobs take the public ProgressReporter, which now resolves by type instead of by class name. The docs sketched a RedisQueueBackend, but no Redis backend ships: they now say so, and state that the in-memory queue is lost on restart and not shared across replicas. Documented under Durability and replicas.
  • Adaptive strategy: MCP tool errors were never recorded -- only raised exceptions counted, but MCP servers report ToolError, validation errors and denied scopes as normal results. Tools built by MCPToolAdapter now raise the new MCPToolError (a ToolException, exported from promptise.mcp.client) for a result with isError or a Promptise {"error": {"code", "message"}} envelope; it carries code, message, retryable and text. The agent loop still shows the model the server's message, and observability and events now see these calls as failed. Code that called an MCP tool directly and inspected the returned error text should catch MCPToolError instead.
  • Adaptive strategy: recording needed observe=True, and failures were logged as tool unknown -- failures are now collected by a per-invocation callback, independent of observability and never shared between concurrent invocations, with the tool's real name, a JSON preview of its arguments, and the MCP error code as error_type.
  • Adaptive strategy: max_strategies and failure_retention did nothing, confidence decay and per_session were documented but missing, and nothing called record_human_correction -- both limits are enforced per partition (synthesized lessons are dropped before human corrections); confidence_half_life and min_confidence implement decay; strategy_ttl expiry also deletes; an approval reviewer's denial reason is stored as a correction for that tool (learn_from_approval_denials, on by default; the policy passed to build_agent is not modified). Human corrections now rank above synthesized lessons: 0.9 unverified, 1.0 when the judge confirms (they were 0.6 against 0.8 for machine lessons).
  • Adaptive strategy: the failure counter reset on every agent rebuild -- it lived on the manager. The count of unsynthesized strategy failures is now read from the memory provider via a new optional list_entries() method (implemented by InMemoryProvider, ChromaProvider and Mem0Provider), so it survives restarts, and each failure is synthesized once even with auto_cleanup=False.
  • Adaptive strategy: the failure classifier matched substrings -- "not found" and "capacity of 500" were classed as infrastructure (from "500"), and "already booked" as unknown. Phrases now match as whole words, HTTP status codes only count next to an HTTP word or reason phrase, MCP error codes (TOOL_ERROR, VALIDATION_ERROR, RATE_LIMIT_EXCEEDED, INTERNAL_ERROR, ...) are understood, and the strategy vocabulary covers conflicts, duplicates, limits and closed resources.
  • InMemoryProvider search found nothing for natural-language queries -- it required the whole query as a substring, so adaptive strategy never retrieved a lesson with it despite the docs saying it "works for testing". Search now ranks whole-query matches first, then entries by the share of the query's words they contain (stop words ignored); entries sharing no word are not returned.
  • .superagent adaptive: section had no scope -- and, forbidding extra keys, rejected it. It now accepts scope and the new feedback_rate_limit, confidence_half_life, min_confidence, allowed_tools, review_lessons and learn_from_approval_denials. build_agent(adaptive=...) also accepts a dict of AdaptiveStrategyConfig fields, and warns when adaptive is set without memory instead of silently ignoring it.
  • MCP client: agents never recovered from a server restart -- after an HTTP server restarted or was redeployed, it answered the old mcp-session-id with 404. The first tool call failed with Session terminated, and MCPMultiClient then dropped the tool from its routing, so every later call — and every agent built with build_agent() — failed with Unknown tool 'check_stock'. Call list_tools() first until the process restarted. As the MCP specification requires, a 404 for the session now opens a new session (initialize) and retries the call once (the server refused it without running it, so a tool never runs twice); concurrent calls share one re-initialisation, and MCPMultiClient re-lists that server's tools so routing follows the new deployment. A connection that drops during a call is not retried (the tool may have run), and the next call reconnects. A call in flight when its transport died used to wait forever; it now fails at once with Connection to ... was lost. If the retry is answered 404 too (replicas without sticky sessions), the error says so.
  • MCP client: a URL without /mcp failed as McpError: Session terminated -- the SDK reports a 404 answer to initialize as a terminated session. Connecting now raises MCPConnectionRejectedError (status_code=404) with the existing hint: Server 'inventory' rejected the connection: 404 Not Found. Check the URL (http://127.0.0.1:8170); Promptise servers serve MCP at /mcp.
  • MCP server SDK: browser clients could not use the session -- CORSConfig had no expose_headers, so a page could not read the mcp-session-id the server assigns on initialize, and the default allow_headers lacked mcp-session-id and mcp-protocol-version, so the browser's preflight was refused (400 Disallowed CORS headers). CORSConfig gains expose_headers (default ["mcp-session-id"]) and the default allow_headers is Content-Type, Authorization, x-api-key, mcp-session-id, mcp-protocol-version, last-event-id. The CORS layer now wraps the transport auth gate, so with require_auth=True a preflight (which never carries credentials) is no longer answered 401, and the gate's 401 carries CORS headers the page can read.
  • Reasoning Graph: agent_pattern="peoatr" never finished -- its plan, think and reflect stages answered in plain text, so their routing never fired; reflect's answer route also led back to act. A run looped act → think → reflect until the 25-step cap (26 model calls on a four-region sales question) and returned the think stage's notes as the answer. PEOATR is now plan → act → think → reflect → answer: plan, think and reflect return structured output (PeoatrPlan, PeoatrThought, PeoatrReflection), answer writes the final reply, and every stage has a budget (plan 3, act and think max_act_steps — new, default 12 — reflect 4); a stage that uses its budget hands over to answer, so a run always ends with a written answer.
  • Reasoning Graph: a re-entered PromptNode reused its first prompt -- the node cached its system prompt in the run config the first time it ran, so when a graph looped back to it, updated input_keys values, plan, reflections and observations were ignored (a node fed "apple" then "banana" answered "apple" twice). The prompt is now rebuilt on every execution; only the model's tool/structured-output binding is reused, and only while the model and tool set are unchanged.
  • Reasoning Graph: a node's max_iterations was not enforced -- only the engine-wide max_node_iterations (25) was checked, after the fact, so a verify node with max_iterations=3 in a loop ran 8 times. The engine now stops running a node once it has executed min(node.max_iterations, max_node_iterations) times (each tool-loop round counts) and follows the node's "error" transition, else an __error__ node, else ends the run. Streaming runs enforce it too.
  • Reasoning Graph: ExecutionReport.total_tokens was always 0 -- token usage was read with getattr from LangChain's usage_metadata, which is a dict. Usage is now read correctly (in CodeActionNode too), and nodes with an output_schema request include_raw=True so their usage is counted as well.
  • Reasoning Graph: a Pydantic output_schema didn't route -- ValidateNode, PlanNode, ReflectNode, CritiqueNode and transitions only read dict outputs, so with a Pydantic model next_node stayed None and the run took the default path; a TypedDict worked. A Pydantic result is now stored as a plain dict (model_dump()) in result.output, output_key and {name}_output, so it routes exactly like a TypedDict.
  • Reasoning Graph: a route field could only name nodes -- a structured {"route": "replan"} (the values ReflectNode's own instructions ask for) was ignored unless a node was literally named replan. A route value that is one of the node's transition keys now follows that transition; node names still work. The routing hint in the prompt lists the transition keys, and never the reserved "error" route.
  • build_agent: unknown agent_pattern names silently ran ReAct -- a typo such as agent_pattern="plan-act-verify" built the default ReAct graph, and agent_pattern="pipeline" failed with TypeError: build_pipeline_graph() got an unexpected keyword argument 'tools'. An unknown name now raises ValueError listing the valid names (promptise.agent.AGENT_PATTERNS) before any MCP server is contacted; "pipeline" explains that a pipeline is built from your nodes: agent_pattern=PromptGraph.pipeline(node_a, node_b).
  • Reasoning Graph: YAML save/load lost most of a graph -- save_graph kept only names, instructions, transitions and a few numbers, and stored tools as name strings that loaded as strings: output_schema, on_pass/on_fail, input_keys/output_key, flags (including inject_tools), plan/critique thresholds, edge conditions and the graph's mode were dropped without a word, and a strategy came back as its class name. The format (now version: 2) round-trips every node setting: data as data, compaction settings as their fields, tools by name (resolved from load_graph(..., tools=agent.tools); a missing tool raises), and schemas, processors, edge conditions and @node functions as "module:QualName" references (resolved from refs=[...], from promptise itself, or — for trusted files — allow_imports=True). The edge helpers (on_output, on_error, …) now use serializable EdgeCondition objects. Anything that can't be represented — a lambda, a strategy/guard/block instance, a model object, an unregistered node class — makes save_graph raise GraphSerializationError naming the node and setting, and loading raises on unknown types or fields instead of guessing. register_node_type classes with constructor parameters of their own need to_config()/from_config().
  • Reasoning Graph: PromptNode(perspective=...) was ignored -- the node looked for a framing attribute that none of the built-in perspectives (analyst, critic, advisor, creative, perspective(...)) have, so the prompt went out unframed and result.perspective_applied stayed None. The node now applies the Perspective protocol (apply(prompt, ctx)); an object with a framing string still works.
  • Reasoning Graph: loop_until(max_iterations=...) was ignored -- the argument was accepted and dropped, so a loop whose exit condition never held ran until the engine's 25-run cap and then ended the run without reaching the exit node. The node now runs at most max_iterations times and then exits to exit_to; the budget is kept by graph.copy() and saved in YAML (loops:).
  • Memory: Mem0Provider did not work with any current Mem0 release -- add() called Memory.add(data=...), which mem0ai 0.1.118, 1.x and 2.x reject (Memory.add() got an unexpected keyword argument 'data'); they take the text as messages. mem0ai 2.0 also moved the user and agent ids of search() and get_all() into filters= and renamed limit to top_k, rejecting the old arguments. The provider now passes messages=, reads the installed client's search() signature once and calls the form it accepts, and was checked against real mem0ai 0.1.118, 1.0.11 and 2.2.1 (add, search, per-user isolation, delete, purge). The [all] extra now pins mem0ai>=0.1.118,<3. The tests that let this through mocked Mem0 with a bare MagicMock; new tests use fakes with the real 0.1/1.x and 2.x signatures and, when mem0ai is installed, bind every call Promptise makes to the installed release's signature. While there: purge_user() returns the number of entries it removed instead of 0.
  • Memory: chat(user_id=...) forgot everything with a per-user memory provider -- chat() checked session ownership against user_id but invoked the agent with no caller, so a PER_USER provider raised MemoryIsolationError, which was logged as "Memory search failed", and the agent answered without memory. As documented, user_id= is now shorthand for caller=CallerContext(user_id=...): memory, the semantic cache, guardrails and observability see the same user as the ownership check. A caller without a user_id gets the one passed; caller.user_id still wins when both are set.
  • Memory: ChromaProvider scored every result 0.00 -- it created collections with Chroma's default squared-L2 distance but scored results as max(0, 1 - distance); typical distances are 1.2–1.5, so every score was 0 and any min_score filter dropped every memory. New collections use cosine distance (metadata={"hnsw:space": "cosine"}) and score is the cosine similarity. An existing collection keeps its distance function and is scored for it (l2: 1 - distance / 2, equal to cosine similarity for the unit-length embeddings Chroma's default model produces), with a warning that says how to copy it into a cosine collection (Scores and distance functions). The provider also no longer passes metadata when opening an existing collection.
  • Memory: settings were fixed, and a slow write was reported as failed -- build_agent() gains memory_max_results (5), memory_min_score (0.0) and memory_timeout (5.0 s); before, they could not be changed. The runtime's ContextConfig.memory_max / memory_min_score (and the .agent manifest's memory.max / min_score) were accepted but never reached the agent; they now do, along with a new memory_timeout (memory.timeout). A memory auto-store write that took longer than the timeout was cancelled and logged as "Memory auto-store failed" while the provider's worker thread kept running and the write landed. The write is no longer cancelled: the agent logs that it has not finished yet, then its real outcome ("completed after 7.2s" or "failed" with the error), and shutdown() waits up to 30 s (or memory_timeout, if longer) for writes still in flight before closing the provider.
  • Observability: with observe=True the semantic cache never hit -- the agent recorded cache events with record(description=...), but the parameter is details=; the TypeError was caught as "Cache check failed, continuing without cache" (and "Cache store failed"), so every call went to the model. Cache events no longer copy the user's query into their text.
  • Observability: the HTML report showed 0 tokens, 0 LLM calls and 0 tool calls, and the Tool / LLM filters hid every row -- the page script looked for llm_end and tool_call_start, but events are named llm.end and tool.call. The stat cards now show the embedded get_stats() numbers, the filters (All / Agent / LLM / Tool / Error / Cache) use the real event names, each event's metadata opens on click.
  • Observability: generate_report("shop-report.html") wrote shop-report-report-<timestamp>.html and ignored title= -- it now writes exactly the given path (creating missing directories) with the given title, returns that path, and raises when the file cannot be written. HTMLReportTransporter gains title= and write(path).
  • Observability: ObserveLevel did not do what the docs say -- OFF recorded everything, BASIC only dropped llm.start, and FULL recorded no prompt or response text. Now OFF records nothing (the agent keeps an empty collector and creates no transporters), BASIC records agent input/output, tool calls and errors and cache events but no per-turn LLM events (agent.output carries the run's token totals, and get_stats() counts them), STANDARD adds every LLM turn, and FULL adds prompt, response, input and output text.
  • Observability: agent input and output were never recorded, and failed tool calls were not counted -- the agent graph fires no LangChain chain callbacks, so agent.input / agent.output never appeared. The agent now records agent.input, agent.output (with the run's duration, tokens, call counts and cache_hit) and agent.error (new event type) for every ainvoke, chat, astream and astream_with_tools call, and every event recorded during a run gets the run's agent.input entry as its parent_id. A tool that reported a failure left error_count at 0: with the server's isError results (see Tool errors were reported as successes) a failed MCP call is now recorded as tool.error, and a tool's ToolMessage with status="error" keeps its tool.result event (with status: "error") but counts in the handler's and get_stats()'s error_count and gives its OpenTelemetry span an ERROR status. tool.error events now carry tool_name and latency_ms.
  • Observability: OpenTelemetry spans were flat -- every event was its own root span lasting microseconds, with the real duration in an attribute and no user, session or correlation id. OTLPTransporter now exports each agent run as one trace: an invoke_agent span with chat <model> and execute_tool <name> children carrying their real start and end times, ERROR status on failed calls and runs, cache hits and retries as span events, GenAI semantic-convention attributes, enduser.id, session.id and promptise.correlation_id. A run started inside an active span (an instrumented web request) joins that trace. It no longer replaces the global tracer provider, and tracer_provider= sends spans through your application's provider instead of a private one.
  • Docs -- conversations.md said chat() still answers when the store fails, but a store error during the ownership check raises RuntimeError. That behavior is kept on purpose (an agent that cannot tell whose session it is must not answer in it) and is now documented step by step under When the store fails; a failure to assign ownership of a new session is now a warning instead of a debug message. observability.md showed generate_report() without its required path and a callback reading entry.description (the attribute is details); the building-agents guide used stats keys and a TransporterType.OTEL that do not exist, build_agent(timeout=) instead of max_invocation_time=, and expected a blocked input to return a response rather than raise GuardrailViolation. examples/memory/multiuser_chat.py called chat(user_message=...) and two methods that do not exist, and shared one memory pool between its users; it now uses a per-user ChromaProvider and chat(user_id=...).
  • Semantic cache: tool turns were replayed and writes never invalidated anything -- an answer built from a tool call was cached like any other, so "Open a ticket" was answered "T-101 opened" a second time without opening a ticket, and "How many open tickets?" kept returning a stale count. invalidate_on_write existed but nothing called it. Now a turn that called a write tool is never cached, turns that called only read-only tools are cached only with cache_tool_turns=True, and a write tool evicts the caller's cached answers as soon as it finishes or fails (also from astream() / astream_with_tools(), and even if the run fails afterwards); a request already running when the write landed does not store its answer. A tool is read-only when its MCP annotations say readOnlyHint: true; an unannotated tool counts as a write.
  • Semantic cache: cache.hit events lacked the similarity and age the docs promise -- they now carry similarity (cosine similarity to the stored query) and age_seconds in their metadata.
  • Semantic cache: a closer entry for another context hid the matching one -- only the most similar entry was checked, so an entry stored under a different context, model or instructions turned a valid hit into a miss. The most similar entry with the same context is served. Each looked-up request now counts once in stats(); a context mismatch counted as a hit and a miss.
  • Semantic cache: fallback answers were keyed as the primary's -- with a FallbackChain, answers were stored and looked up under the model configured at build time. They are stored under the model that wrote them and looked up under the model currently serving.
  • Semantic cache: a missing sentence-transformers silently disabled caching -- every request logged an embedding warning and cached nothing. build_agent(cache=SemanticCache()) now raises ImportError naming the package (cache.check_dependencies()).
  • FallbackChain could not drive an agent with tools -- it had no bind_tools(), so build_agent(model=FallbackChain([...])) with any tool failed on the first model call with NotImplementedError. bind_tools() binds the tools to every model in the chain; the bound chain shares the circuit breakers. Each answer records its model in response_metadata["fallback_model"].
  • FallbackChain did not stream -- astream_with_tools() got the answer in one piece. It now streams from the first model that starts answering; a model that fails before its first chunk is skipped (the timeouts bound the wait for that chunk), and a failure after it is raised rather than spliced onto another model's answer.
  • FallbackChain: one bad request could open a healthy model's circuit -- every exception counted toward the breaker, so three oversized prompts (HTTP 400 "context length exceeded") took the primary out of rotation for every user for recovery_timeout seconds. Requests the provider rejects (HTTP 400, 413, 422) still fall back but no longer count. The "all models failed" RuntimeError now has the last error as its __cause__.
  • RAG: a loader that overrides only iter_load() could not be indexed -- RAGPipeline.index() called load(), which raises in a loader that streams through iter_load() (the docs recommend that for large sources). The pipeline now reads every loader through iter_load(), and embeds and stores about batch_size chunks at a time instead of holding the whole source in memory.
  • RAG: an embedder returning the wrong number of vectors was not reported -- the batch was skipped with only a log line, so IndexReport.errors stayed empty while chunks_stored came up short. Each affected document is now in errors. RAGPipeline(batch_size=0) raises ValueError instead of failing inside index().
  • RAG: RecursiveTextChunker edge cases -- a cut into fixed windows (text without separators, e.g. Chinese or Japanese) added one or more trailing chunks that only repeated the tail of the previous one ("abcdefghij" with chunk_size=6, overlap=4 gave five chunks instead of three). "" in separators, listed in the guide's defaults, raised ValueError: empty separator; it now means the character-level cut. separators=[] was replaced by the defaults; it now means fixed windows only.
  • RAG: InMemoryVectorStore scores and limits -- a NaN embedding scored 1.0 (RetrievalResult clamped NaN to the top score) and broke the ordering; non-finite scores are now 0.0. A query vector of a different dimension (an index built with another embedder) scored every chunk 0.5 and returned arbitrary chunks; it now raises ValueError, as does a query that doesn't match dimension=. limit=-1 returned every hit but the last; RAGPipeline.retrieve() now raises for limit < 1 and the store returns nothing. Results and stored chunks are copies, so changing a result's metadata no longer changes the stored chunk.
  • RAG: rag_to_tool() ignored an unknown format -- format="jsn" silently produced markdown; it now raises ValueError. The limit the model can pass must be at least 1 (a negative limit used to drop results).
  • MCP server SDK: AuditMiddleware log files did not verify as written -- include_args=True logged the public keys of ctx.state (the tool definition's repr, with handler and schema) and never the tool's arguments. Entries were written after the chain lock was released, so concurrent calls could land in the file out of chain order (30 concurrent calls: the file order differed from the chain). A failed write still advanced the chain, so the next entry looked like it followed a deleted one; now the error is logged and the chain stays put. Every restart began a new chain at the genesis hash in the same file, so entries just before a restart could be deleted without a trace; a server now continues the chain from the file's last entry (marked "resumed": true) and closes an incomplete last line left by a crash instead of appending to it. Entries were signed before JSON conversion, so arguments with non-string dict keys ({2: ..., 10: ...}) were signed in an order the file doesn't have and failed verification. audit.entries grew without limit. Entries now carry seq, their position in the chain, and each is written with a single append.
  • MCPcast: generated tools advertised bare types, not the spec's schema -- a parameter reached tools/list as {"type": "string"} or {"type": "integer"}: the spec's enum, pattern, minimum/maximum, minLength/maxLength, format, default, nullable, oneOf/anyOf, nested object shapes with their required lists and nested descriptions were all lost, so an agent had to guess what the API accepts. Each generated tools module now carries PARAM_SCHEMAS, and @advertise(...) puts each parameter's JSON Schema from the plan into the tool's input schema. OpenAPI 3.0 dialect is translated (nullable: true and Swagger 2's x-nullable become a "null" type or union member, and an enum gains null; boolean exclusiveMinimum/exclusiveMaximum become numbers; example becomes examples); vendor extensions, discriminator, xml and leftover $ref keys are dropped. The handler still validates the base type, and the API stays the authority on the rest. The generated test suite checks that every parameter advertises its schema. Generated projects now require promptise>=1.2.1.
  • MCPcast: schema details were lost before the plan -- Swagger 2 parameters kept only type, format, enum, default, items, minimum, maximum and pattern (now also minLength, maxLength, exclusiveMinimum, exclusiveMaximum, multipleOf, minItems, maxItems, uniqueItems and x-nullable); an OpenAPI 3 parameter declared through content instead of schema became a bare string; an example on the parameter itself was ignored (it now feeds the tool's example).
  • MCPcast: readOnly body properties were required inputs -- a schema shared by requests and responses (id: {readOnly: true} listed in required) made the agent invent an id for every create call, or the call failed validation. readOnly properties are no longer parameters, nested ones are left out of the advertised schema, and their names leave the required lists.
  • MCPcast: a required nullable body property could not be null -- the handler took int only and dropped None, so clearing a field the API allows to be null failed with VALIDATION_ERROR. A required JSON body property the spec marks nullable now accepts None and is sent as null (Route.nulls); an optional parameter's None is still "not sent".
  • MCPcast: oversized schemas are trimmed and reported -- one parameter advertises at most 4,000 characters of schema and one tool 24,000; past that, nested descriptions, then nested structure, then the enum are cut so a page-long body or an enum of thousands of entries cannot bloat every tools/list. trimmed_schemas(plan) names such tools, and both the guided setup's review and the Agent Readiness report flag them.
  • MCPcast Agent Readiness: every upstream "not found" was blamed on the plan's example -- the report told you to replace example values even when the example was valid and the identifier came from the task, from a mocked write's reply, or from the agent's guess. The evaluation now records the mocked replies of each task (TaskResult.mocked) and names the cause: the plan's example (fix it), the agent guessing (say in the description where the identifier comes from), or the task or a mock (nothing to change). The task writer is told not to invent identifiers that no example shows.
  • Agents crashed listing tools whose schema uses a type list or a nullable union -- promptise.tools (which turns each MCP tool's input schema into the model the agent sees, for build_agent(), MCPToolAdapter and the MCPcast readiness evaluation) raised TypeError: unhashable type: 'list' on "type": ["string", "null"] (JSON Schema, OpenAPI 3.1, any nullable MCPcast parameter) and TypeError: typing.Optional requires a single type on anyOf: [string, integer, null] (FastAPI's Union[str, int, None]), so one such parameter broke every tool of the server. Both are now unions; OpenAPI 3.0's nullable: true is Optional too.
  • Agents saw tool parameters without their constraints -- the same conversion dropped pattern, minLength/maxLength, minimum/maximum, exclusive bounds, multipleOf, format, minItems/maxItems/uniqueItems, integer and nullable enums, and the enum or constraints of array items. They now reach the model's tool schema (integer enums and const as Literal, other enums and the constraints as schema keywords). Constraints are shown, not enforced: the server validates its own arguments, and a JavaScript regex need not compile in Python.
  • Adaptive strategy: failures recorded in the same clock tick were never learned from (Windows) -- synthesis marked failures as consumed with a timestamp watermark and only picked up failures strictly newer than it. On Windows, where time.time() ticks every ~15 ms before Python 3.13, a failure recorded right after a synthesis carried the same timestamp and was skipped for good, and the threshold under-counted. Adaptive entries now carry a seq (microseconds, strictly increasing per process) used for ordering, and the watermark stores the seq plus the ids consumed at it, so a tie with another process sharing the provider is not lost either.
  • Durations read 0 ms on Windows -- latencies and durations (prompt latency_ms and PromptInspector traces, observability spans and LLM/tool/chain durations, invocation.*/tool.* event duration_ms, reasoning-engine node timings, guardrail scans, RAG indexing, sandbox commands, MCP server audit entries) were measured with time.time() or time.monotonic(), which tick every ~15 ms on Windows, so fast steps reported 0. They now use time.perf_counter().
  • OpenAPIProvider read spec files in the platform encoding -- a JSON or YAML spec file was read with the locale's default encoding (cp1252 on Windows), so non-ASCII titles and descriptions were garbled or failed to decode. Spec files are now read as UTF-8.
  • .superagent: rooted stdio paths moved onto the file's drive on Windows -- command: and cwd: were joined to the file's folder unless Path.is_absolute(), which is false on Windows for a path with a root but no drive, so cwd: /tmp became C:\tmp (the drive of the .superagent file) and command: \tools\server.exe likewise. A path with a root is now used as written; relative paths still resolve against the file's folder. A YAML parse error caused by a backslash escape in double quotes (command: "C:\Python312\python.exe") now says to use single quotes or forward slashes, and .superagent files documents it.
  • MCP server crashed at startup on Windows when stdout was redirected -- server.run(transport="http") (and "sse") prints a startup banner drawn with box characters. A redirected stdout on Windows (a service, python server.py > server.log, a parent process capturing output) uses cp1252, which cannot encode them, so the server died with UnicodeEncodeError before it bound its port. The banner now falls back to an ASCII box, and to ? for any other character the stream cannot encode.

Security

  • Events: WebhookSink could be pointed at internal hosts after the SSRF check -- the URL was checked once, when the sink was created, and httpx resolved the name again for every delivery, so a DNS record that changed afterwards (DNS rebinding, or a host that did not resolve at startup) delivered events, with their signature, to 127.0.0.1, a private network or a cloud metadata service. The host is now resolved and checked before every delivery and the request goes to the checked address (original Host header and TLS server name kept), so httpx never resolves the name again; a delivery to an internal address is dropped with a warning and not retried. Redirects are never followed. The shared private-address check (also used by WebhookApprovalHandler, escalation webhooks and OpenAPIProvider) now rejects every non-public address, which adds shared/carrier-grade NAT space 100.64.0.0/10 (Alibaba Cloud's metadata service is 100.100.100.200), 0.0.0.0 and multicast.
  • Approval and escalation webhooks could be pointed at internal hosts after the SSRF check -- the follow-up to the WebhookSink fix above. WebhookApprovalHandler (used by the agent's ApprovalPolicy and by the MCP server's ApprovalGateMiddleware) checked url and poll_url once, at construction, and the runtime's escalation webhook (EscalationTarget.webhook_url for budget, health and mission) checked its URL and then let httpx resolve the name a second time; either way, a DNS answer that changed in between (DNS rebinding, a short TTL) sent the signed approval request, every poll, or the escalation payload to 127.0.0.1, a private network or a cloud metadata service. Both now use the WebhookSink code, moved into a shared helper: the host is resolved before every request (the approval POST and each poll), any non-public address refuses the request, and the request goes to the checked address with the original Host header and TLS server name. A refused approval request raises, so the tool call is denied (fail closed); a refused escalation is dropped with a warning. Redirects are never followed, even when WebhookApprovalHandler is given an http_client that follows them. allow_private_networks=True keeps skipping the check. See SSRF Protection.
  • OrchestrationAPI: PATCH budget, health and mission wrote unvalidated values into the live config -- the body's fields were setattr onto the running process's BudgetConfig, HealthConfig and MissionConfig without validation, so {"max_tool_calls_per_run": -5}, {"error_rate_threshold": "high"} or {"on_exceeded": "ignore"} was accepted with 200, a misspelt field was silently dropped, and escalation was stored as a plain dict the runtime could not use. The patched section is now validated against its config model (the limits used at deploy time, strict JSON types, unknown fields refused, including inside escalation and tool_costs) and answered with 422 INVALID_CONFIG or 422 UNKNOWN_FIELD without changing anything; valid patches are applied in place. See Configuration Updates.
  • Events: credentials in URLs were half redacted -- the email pattern ran before the URL-credentials pattern and consumed password@host, so postgres://admin:s3cret@db.internal/app in an event (a connection error, say) went out as postgres://admin:[EMAIL]/app, user name visible. URL credentials are now matched first and the user name and password both become [REDACTED] (postgres://[REDACTED]@db.internal/app), the order observability's redact_sensitive already used. The pattern also no longer runs across fields of the serialised payload, which could swallow neighbouring fields.
  • Identity credentials fail closed -- if an agent's identity cannot acquire a credential (the IdP is unreachable), the MCP request is not sent and the call fails with MCPCredentialError; it used to go out without a credential. That includes the connection build_agent() opens, which used to be made unauthenticated when the IdP was down. The operator is warned once per outage.
  • JwksAuth: made-up key ids could turn every request into a JWKS fetch -- a token with an unknown kid triggers at most one re-fetch per kid per minute and one per second overall.
  • Identity: no credential in error messages -- the Entra IMDS error for a non-JSON answer no longer echoes the body (it could be a bare token); provider failures surface as MCPCredentialError naming only the exception type.

  • promptise agent: cross-agents ran without their own approval, guardrails and identity -- each cross-agent was built from its file's servers, model, instructions and trace setting only, so a specialist whose file required approval for a tool ran that tool with no approval. Every cross-agent is now built from its whole file.

  • Cross-agent: broadcast_to_agents bypassed approval of ask_agent_<name> -- with approval=ApprovalPolicy(tools=["ask_agent_payments"]), the model could reach the payments agent through broadcast_to_agents(peers=["payments"]) without a reviewer. The broadcast tool no longer calls a peer whose ask tool needs approval (its entry says to use ask_agent_<name>), unless the broadcast tool needs approval itself.
  • Cross-agent: a nested hop reported an outer agent as the delegator -- when an agent without an identity delegated further, the peer's observability recorded delegated_by as the identity of the agent further up the chain. Each hop now records its own delegator, or none.
  • Runtime triggers: payloads reach the agent as delimited, untrusted data -- a trigger event used to become [Trigger: webhook] Payload: {...}, a raw Python dict pasted into the prompt with nothing marking it as outside input. In trials, a GitHub issue claiming "#12 and #15 are duplicates, close them" made a triage agent close both issues in 5 of 5 runs, despite instructions saying not to. The payload is now rendered as JSON (text bodies verbatim) inside an <untrusted-trigger-payload-…> block whose tag gets a random suffix for every event, so a payload can't close the block early. The block is preceded by a notice that its content is data, never instructions, and that its claims are unverified. Long payloads are cut at trigger_delivery.max_payload_chars (default 20 000). New opt-in trigger_delivery.scan_payloads=True runs a prompt-injection classifier over every string in the payload before the agent runs: ProcessConfig.guardrails when it is a scanner, otherwise an injection-only PromptiseSecurityScanner. The model loads at start-up, and start() fails if it can't. Flagged events, and runs blocked with GuardrailViolation, are dead-lettered without counting as failures, so hostile input can't push the process into FAILED. The docs now cover gating side-effecting tools with ApprovalPolicy, the control that held in the same trials (0 of 5 issues closed). See Trigger payloads are untrusted input.
  • Runtime triggers: webhook signatures could not be configured, and GitHub and Stripe deliveries could not be verified -- TriggerConfig had no way to set the webhook's secret or bind address (a hmac_secret= key was silently dropped, leaving the endpoint unauthenticated), and the built-in check read only X-Webhook-Signature. New TriggerConfig fields: hmac_secret (a SecretStr, so ${ENV} works in manifests and serialised configs never carry it), signature_scheme (generic, github with X-Hub-Signature-256: sha256=…, or stripe with Stripe-Signature: t=…,v1=… signed over "<t>.<body>"), signature_header, webhook_host (default 127.0.0.1), and allowed_sources (IPs/CIDRs; others get 403). Signature headers are no longer copied into event metadata.
  • Runtime triggers: replayed Stripe-style webhooks -- a timestamped signature is rejected when it is older than signature_tolerance (default 300 s), and a request whose signature was already accepted is refused for the rest of that window, so a captured delivery can't be sent again to re-run the agent.

  • Runtime: RuntimeTransport served its control API without authentication on public binds -- with no auth_token, RuntimeTransport(host="0.0.0.0") only logged a warning and then let anyone who could reach the port list, start, stop and restart processes and inject trigger events (agent runs with attacker-chosen payloads); the docs' own example did exactly that. A non-loopback bind without auth_token now raises ValueError; allow_unauthenticated=True is the explicit opt-out for nodes behind an authenticating proxy or a private network. An empty auth_token is refused.

  • Runtime: a web page could drive a local RuntimeTransport -- on a loopback bind without a token, any site open in a browser on the same machine could POST to http://127.0.0.1:9100/processes/<name>/event (a text/plain "simple request" needs no CORS preflight, and the JSON body was parsed regardless of content type) or reach it through DNS rebinding. Loopback binds now refuse requests whose Host is not a loopback name (421) and requests from a non-loopback Origin (403). The bearer check runs in one middleware for every route except GET /health, and event bodies must be JSON objects (payload and metadata too).
  • Runtime: a web page could drive a local OrchestrationAPI -- the management API required a token only for public binds, so on 127.0.0.1 without one any site open in a browser on the same machine could post to it (a cross-site form post with a JSON body needs no CORS preflight), and a DNS-rebinding page could also read its answers: deploy processes, change instructions, send messages, stop the runtime. On a loopback bind it now refuses a Host header that isn't a loopback name (421) and an Origin from another site (403), the protection RuntimeTransport already has. Request bodies must be JSON objects (400 otherwise), and the token is checked in one place for every route except GET /api/v1/health, unknown paths included. A malformed message body or a non-ASCII token gave a 500; they are now 400/422 and 401.
  • Runtime: RuntimeCoordinator could not manage authenticated nodes -- it sent no credentials, so a node protected with auth_token answered every status and control request with 401, and the coordinator docs told users the node API had no authentication. It now sends Authorization: Bearer <token> from RuntimeCoordinator(auth_token=...) or register_node(..., auth_token=...).
  • MCP server SDK: resource reads and prompt requests skipped all middleware -- resources/read and prompts/get called the handler directly, so logging, audit, rate limits, timeouts, AuthMiddleware and guards never ran for them: a resource or prompt served private data to any client, even on a server whose every tool required authentication. Both now run through the same pipeline as a tool call (argument conversion, dependency injection, middleware chain, guards, exception handlers). @server.resource, @server.resource_template and @server.prompt (and the MCPRouter equivalents) take auth, roles, guards, rate_limit, timeout and tags like @server.tool; require_auth / require_tenant, router auth / guards / middleware, and declared rate limits cover them too. A denied request is an MCP error and the handler never runs; unhandled handler errors return a generic message, never the exception text. Middleware tells the three apart with the new ctx.request_type ("tool", "resource", "prompt"); a resource read sets ctx.state["resource_uri"], and AuditMiddleware records both.
  • MCP server SDK: CacheMiddleware and resources / prompts -- now that resource reads and prompt requests run through the middleware chain, CacheMiddleware caches them too, keyed on the request type and URI as well as the arguments and caller, so a tool and a prompt sharing a name, or two URIs of one template, never share an entry; a hit re-checks their guards like a tool's. A tool, resource or prompt that requires authentication now bypasses the cache while the caller is unidentified (CacheMiddleware added before AuthMiddleware) whatever the scope: with scope="shared" and no guards, a hit used to skip authentication.
  • Conversation flows: one conversation's state leaked into every other -- build_agent(flow=...) kept the single flow instance it was given and advanced it on every call, from every session and every user. One customer's phase, ctx.state, history and filled slots (an order number, an account) went into the next customer's system prompt, and the model answered from them. The agent now keeps one flow per conversation: per session_id (scoped to the caller or user_id) when chat() or the new ainvoke(..., session_id=...) names one, otherwise per CallerContext (tenant and user). A call with neither gets a throwaway flow built from the messages it carries, so nothing is shared. The flow you pass is a template and is deep-copied per conversation; you can also pass the class or a zero-argument factory (flow=lambda: SupportFlow(business=True)). A template that can't be deep-copied fails at build_agent() with a TypeError that says to pass a factory. Turns of one conversation are serialized; flows are kept for up to 10,000 conversations (least recently used dropped first), and a dropped or new flow is rebuilt from the conversation's user messages, so it survives a restart when a conversation store holds the history. delete_session() drops the session's flow, and agent.get_flow(session_id, caller=..., user_id=...) returns a conversation's flow. Behaviour change: the instance you pass to build_agent() is no longer advanced; read a conversation's flow with get_flow(). Documented under Integration with build_agent.
  • Agent: the invoking user's token now reaches MCP servers, per call -- CallerContext.bearer_token was documented as sent to every MCP server, but build_agent() opened one long-lived session per server with the headers from HTTPServerSpec, so every tool call carried the credential the agent was built with. An agent built with one user's token and shared by others answered every user as that first user (an agent built with Alice's token returned Alice's identity and data to Bob). Now every tool call to an HTTP/SSE server during an invocation whose CallerContext has a bearer_token goes over an MCP session opened for that token: one session per distinct token, reused by later calls with the same token, closed after 5 idle minutes, at most 256 idle sessions (least recently used first), never shared between tokens, and never closed while a call is using it. Concurrent invocations never share a session or a header dict, and the token is never stored as a pool key (SHA-256 digest). Tool discovery and invocations without a caller token keep using the spec's credentials. HTTPServerSpec(forward_caller_token=False) opts a server out (for third-party servers that must not receive your users' tokens). stdio servers have no request headers: they never receive the token, and a warning is logged once per server. A rejected caller token fails only that tool call (401 reported to the model); other callers are unaffected. New API: MCPMultiClient.call_tool(..., bearer_token=), MCPMultiClient(max_caller_sessions=, caller_session_idle_timeout=), MCPClient.with_bearer_token(), MCPClient.supports_bearer_token, MCPToolAdapter(forward_caller_token=). Behaviour change: an agent whose callers carry a bearer_token now presents that token instead of the spec's bearer_token / agent identity on tool calls; set forward_caller_token=False where the server must keep seeing the agent. A per-caller session keeps its server's elicitation callback, so server-side approval gates still reach the agent's approval handler, and the request is related to the call in flight on that caller's session (never to another caller's call on the agent's own session).
  • MCP server SDK: CacheMiddleware served one caller's results to everyone -- its key was the tool name plus ctx.state["arguments"], which the server never sets, so it was the tool name alone: every tenant and every argument got the first caller's cached result (get_account("A-102") returned A-101; Globex received Acme's accounts). Entries are now keyed on the server, the tool, the validated arguments and the caller (issuer, tenant and client id). A cache hit re-runs the tool's guards, so a cached result is never returned to a caller the tool would refuse. When CacheMiddleware runs before AuthMiddleware, authenticated tools bypass the cache (warning logged once) instead of sharing entries between unidentified callers.
  • MCP server SDK: @cached leaked across tenants, or never hit -- the key was the function name plus every keyword argument. With a ctx: RequestContext parameter the key contained a fresh request id, so the cache never hit; without one, a handler that read the caller through get_context() served one tenant's data to the next. Entries are now keyed on the request's server, the function's module and qualified name, the tool's own arguments (injected RequestContext, Depends values and the like are excluded) and the caller. A custom key_func is prefixed with the server and the scope, so it cannot widen sharing. Two functions with the same name in different servers or modules no longer collide on the shared default backend.
  • MCP server SDK: guard denials named other tenants and clients -- HasTenant answered a refused caller with every allowed tenant (Requires tenant in [acme]) and RequireClientId with every allowed client id. Denials now name only the caller's own tenant or client id.
  • Guardrails: ML heads failed open -- when transformers was not installed, a model failed to load or run, Ollama was unreachable for ContentSafetyDetector or Azure rejected the request, scan_text logged a warning (skipping ML injection detection) and passed the text, while report.scanners_run still listed the head. PromptiseSecurityScanner.default() on a core install therefore let every prompt injection through. Scans now fail closed: a head that cannot run adds a CRITICAL BLOCK finding (category scanner_unavailable), so check_input / check_output raise GuardrailViolation, and the head is reported in the new ScanReport.scanners_skipped (head → reason) instead of scanners_run. PromptiseSecurityScanner(..., fail_open=True) (also accepted by default()) restores pass-through, still reporting the skip and logging a warning. warmup() now also checks that Ollama is reachable and llama-guard3 is pulled, and raises on any missing library or model whatever fail_open says.
  • Guardrails: injection after ~570 characters was never classified -- the injection and toxicity heads read only text[:512], so padding a message pushed an attack past the classifier. Both now classify the whole text in overlapping 512-character windows (256-character overlap) and report the highest-scoring window. GLiNER NER and content safety (Llama Guard / Azure) scan in windows too instead of truncating at 5,000 / 4,000 / 10,000 characters.
  • Sandbox: a custom sandbox config no longer re-opens the network, and network="restricted" fails closed -- the SandboxConfig default was network="restricted", and agent_pattern="code-action" only set network="none" when no sandbox= was given, so sandbox={"memory_limit": "512M"} put generated code on the bridge network. Restricted mode relied on iptables, which the default python:3.11-slim image does not ship; the sandbox printed a warning and ran with an open network, so generated code could reach the internet. The default is now network="none" for every sandbox (sandbox=True, a dict, a SandboxConfig, a .superagent sandbox: section and code-action) unless network is set explicitly. "restricted" installs its egress filter (loopback, DNS, TCP 80/443; IPv4 and IPv6 through iptables and ip6tables) in a privileged exec before any sandboxed code runs, and if that fails for any reason the container is removed and the session refuses to start with a message that says how to fix it.
  • Sandbox: a timed-out command is now killed -- execute(timeout=...) returned after the timeout but left the process running, so a while True: kept burning the container's CPU and memory until the session ended. Every exec now carries a marker; on timeout its processes and all their descendants are frozen and killed inside the container, and if that is impossible (for example a fork bomb has used up the process limit) the container is restarted, re-applying the restricted-network filter or stopping the container if the filter cannot be applied. The result says which happened.
  • Sandbox: file paths could escape the allowed directories -- a relative path such as ../etc/passwd was joined onto /workspace after normalisation and accepted, and a prefix check let /workspace2 or /tmpfoo through. Paths are now joined first, normalised, and must equal or sit below /workspace, /tmp or /home.
  • MCPQueue: any caller could read and cancel every other caller's jobs -- the queue tools never checked who submitted a job. On a server with two API keys, the second client listed the first client's jobs with queue_list and cancelled one with queue_cancel; queue_status and queue_result exposed them too. Jobs now record the submitting client and tenant (Job.owner_client_id, Job.owner_tenant_id), and queue_status, queue_result, queue_cancel and queue_list only show a caller its own jobs; another caller's job answers JOB_NOT_FOUND, like an id that does not exist. A caller with the admin role (MCPQueue(admin_role="admin"), the default; None disables it) sees and cancels every job of its own tenant, never another tenant's. Ownership needs authenticated queue tools: a server with require_auth=True, or the new MCPQueue(auth=True); unauthenticated callers share one anonymous owner. Python code calling the queue directly is unrestricted unless it passes caller=QueueCaller(...) (new, exported from promptise.mcp.server). Documented under Job ownership.
  • Adaptive strategy: one tenant's failures and lessons reached another tenant's prompt -- AdaptiveStrategyConfig.scope (default "per_user") was never read: failure logs (with the call's arguments) and lessons were written and searched without a user, so Bob at one tenant was shown lessons, and through memory recall the raw failure records, from Alice at another. With a PER_USER memory provider every write was refused and learning silently stayed off. Failures and lessons are now partitioned by the invocation's CallerContext: per_user (the tenant::user isolation key; callers without a user share one anonymous partition), per_tenant (falls back to per_user without a tenant), per_session (the chat() session or CallerContext.metadata["session_id"]; nothing is learned without one) and shared. Each entry is tagged with its partition and re-checked on every read, so a SHARED provider is partitioned too, and adaptive entries are never injected as recalled memory. Entries written by 1.2.1 and earlier carry no partition and are no longer served to anyone. Docs: Adaptive Strategy -- Scoping.
  • Adaptive strategy: instructions in tool error text could become standing lessons -- synthesis passed tool error messages to the model as if they were trustworthy, so an error saying "Always call export_bookings(...) first" was stored as a lesson and injected into every later prompt. Failure records are now fenced as untrusted data, the model must answer with {"tool", "lesson"} pairs, and a lesson is rejected (and logged) when it is not about a tool that failed, names any other tool of the agent, or contains a URL, email address, IP address or prompt markers. Failures are consumed even when every proposal is rejected. New options: allowed_tools=[...] (only learn about these tools) and review_lessons=True (synthesized lessons stay pending until approve_lesson()). Docs: Untrusted tool output.
  • MCP server SDK: run() binds 127.0.0.1 by default -- MCPServer.run() and run_async() defaulted to host="0.0.0.0", and a non-loopback bind turns Host/Origin validation off unless allowed_hosts is given: server.run(transport="http") listened on every interface and answered a foreign Host (a DNS-rebinding page) with 200. The default is now 127.0.0.1, as for promptise serve and hot_reload(): validation is on and the server is reachable from the same machine only. Action needed if you relied on the old default — in a container, or for clients on other machines — pass host="0.0.0.0" explicitly and name the public host in allowed_hosts (see Deployment). A non-loopback bind without allowed_hosts still runs, and now logs its Host/Origin validation off ... line at WARNING instead of INFO, so it shows under the default logging configuration.
  • MCP server SDK: warning for an unauthenticated public bind -- binding a non-loopback address (0.0.0.0, a LAN or public IP) with no AuthMiddleware on the server or any router now logs a WARNING at startup (MCP server 'name' is bound to 0.0.0.0:8080, which is reachable from other machines, and has no AuthMiddleware: ...), since every tool is then callable by anyone who can reach the port. The server still starts; add AuthMiddleware, front it with an authenticating gateway, or bind 127.0.0.1. MCPcast-generated servers and promptise serve share this code path.
  • MCP server SDK: CORSConfig refuses allow_origins=["*"] with allow_credentials=True -- Starlette's CORS layer answers that combination by echoing every requesting origin back with Access-Control-Allow-Credentials: true, so any website could make credentialed requests to the server and read the answers. CORSConfig(...) now raises ValueError; list the origins explicitly (see CORS Configuration). Behaviour change: code that built this combination fails at construction instead of starting. The defaults (no origins, no credentials) are unchanged.
  • Approval: classifier rules matched redacted arguments -- ApprovalPolicy redacted the arguments before handing them to the handler, and redact_sensitive=True is the default, so an AutoApprovalClassifier only ever saw [EMAIL]: a deny rule ApprovalRule(tool="send_*", argument_contains="@competitor.example") never fired and the email went to the fallback (or an allow rule). The gate now also passes the real arguments as ApprovalRequest.raw_arguments (never serialized, signed or shown in repr()); the classifier's rules, predicates, read-only check and LLM classifier use them, and the fallback gets the reviewer's redacted copy without them. Redaction now applies only to what reviewers, webhooks, events and on_decision see. Classifier rules also work with include_arguments=False, on the agent's gate and on the server-side ApprovalGateMiddleware.
  • Observability exported secrets and PII -- with observe=True, tool arguments, tool results, tool and LLM error messages, and (at FULL) prompt text were written unfiltered to the HTML report, the NDJSON stream, log files, webhooks and OpenTelemetry spans, so an API key, Bearer token or database URL with a password that passed through a tool ended up in your trace backend. promptise.events already had the redaction patterns (its comment said they were shared with observability), and ObservabilityCollector already took a sanitizer, but build_agent() never connected them. The collector it creates now scrubs every event's metadata and details with the new promptise.observability.redact_sensitive (API keys, AWS and GitHub tokens, Bearer tokens, URL credentials, card numbers, SSNs, email addresses), controlled by ObservabilityConfig(redact_sensitive=True) (the default). A run that fails on a guardrail records the blocked categories in agent.error, not the violation message, which quotes the matched text (an NER finding reads person detected: '<name>'). A collector passed as observer= keeps its own sanitizer. See What text is recorded.
  • Memory: a tenant could delete another tenant's Mem0 memories -- Mem0Provider.delete(memory_id, user_id=...) in PER_USER scope passed the id straight to Mem0, whose delete() takes only an id, so any user could delete another user's entry by id. It now loads the entry first and returns False unless it belongs to the caller, as InMemoryProvider and ChromaProvider already did.
  • Memory: astream_with_tools() stored unredacted text in long-term memory -- it stored every exchange even with memory_auto_store=False, without the caller's user id (a PER_USER provider refused the write; a shared one took it), and the raw streamed text from before the output guardrails redacted it, so PII or secrets the guardrails removed from the reply were written to memory and could be injected into later runs. It now follows the same rules as ainvoke(): only with memory_auto_store=True, scoped to the caller, storing the redacted reply.
  • Observability: tool output or user input could inject script into the HTML report -- the report embedded the event JSON in a <script> element unescaped and rendered event text with innerHTML, so a tool result or user message containing </script><script>… or <img onerror=…> ran in the browser of whoever opened the report. The embedded JSON now escapes <, > and &, and all event text is rendered with textContent.
  • Semantic cache: a cached answer could skip an approval gate -- every turn was cached, tool calls included, so asking again replayed the first answer without calling the tool: a read gated by approval=ApprovalPolicy(...) was answered without asking the reviewer, and a denial was replayed even after the reviewer would have approved. A turn that called an approval-gated tool is now never cached (see the tool-turn change under Fixed).
  • Semantic cache: scope="per_session" shared answers between users -- the partition was keyed by metadata["session_id"] alone, so two users passing the same session id (often a short counter or a guessable value) read each other's cached answers. A session partition now belongs to the user and session together, and a caller without a user_id is not cached, as with per_user.
  • Semantic cache: a hit returned the original asker's messages -- the whole stored graph output came back, so with scope="shared" a paraphrase match handed Bob the message Alice had sent ("I'm Alice (acct 4417). Opening hours?"), plus any injected context and tool results. The cache now stores only the final answer, and a hit returns the current request's messages followed by it.
  • Semantic cache: one conversation's follow-up was served in another -- the context fingerprint counted messages without reading them, so "What river runs through it?" after a turn about Paris was answered "The Seine" in a conversation about London. Requests that carry earlier turns now bypass the cache (cache_multi_turn=False, the documented default that was never enforced); with cache_multi_turn=True the history's roles, content and tool calls are hashed into the key.
  • RAG: retrieved documents reached the model as plain text -- rag_to_tool() returned document text with nothing marking it as data, so instructions planted in an indexed document (a wiki page, a support ticket) read like the tool's own output. The "markdown" and "text" formats now put the results in a block delimited by a random per-call tag (<retrieved-documents-…>) that a document can't close, after a notice that the content is untrusted data and its instructions must not be followed. The "json" format now returns {"notice": ..., "results": [...]} instead of a bare array.
  • RAG: removed text and deleted documents stayed retrievable -- re-indexing a document that had become shorter left its old trailing chunks in the store, so text removed from the document (a leaked secret, an outdated policy) was still returned. Indexing a document now replaces it: its old chunks are removed before the new ones are stored (a document whose new chunks fail to embed keeps its old version). On a store without delete_by_document(), delete_document() deleted nothing and returned 0, although the docs promised a fallback; the pipeline now deletes the chunk ids it indexed for the document.
  • RAG: a None metadata filter matched chunks without the key -- InMemoryVectorStore compared metadata.get(key) == value, so filter={"tenant_id": caller.tenant_id} for a request without a tenant returned every chunk that had no tenant_id. A filter key must now be present on the chunk.
  • MCP server SDK: a signed audit log file could be written with a key nobody had -- AuditMiddleware(log_path=..., signed=True) with neither hmac_secret nor PROMPTISE_AUDIT_SECRET signed with a random per-process key and only logged a warning, so the file could never be verified by anyone. Behaviour change: it now raises ValueError; set PROMPTISE_AUDIT_SECRET (or pass signed=False). In-memory use (no log_path) still falls back to a random key. include_args=True also no longer writes ctx.state, where other middleware keep their own data, to the log (see Fixed).

Documentation

  • Events -- docs/core/events.md listed 20 of the events; budget.warning, budget.daily_reset and health.recovered were emitted but undocumented. The page now lists all 25 (with tool.progress and process.restarted) and each event's data fields, and documents signature verification, allow_private_networks, per-sink delivery and the shutdown timeout; the .superagent events: section gains shutdown_timeout and slow_tool_threshold.
  • docs/guides/context-lifecycle.md describes how compaction works and how to tune it; it said the ReAct default was "full" (it is "auto"). docs/core/context-engine.md showed register_layer() (the method is add_layer()) and a 128k window for GPT-5, and now documents how the engine works with build_agent. docs/core/engine-nodes.md documents compaction=.
  • .superagent files -- version: was documented as required; it is optional and defaults to "1.0". The schema reference now covers approval:, the delegation fields, the HTTP credentials and team building; the cross-agent page documents limits, timeouts, tracing and how approval and identity apply to delegation.
  • Prompting docs corrected -- the flows and overview pages showed build_agent(...) without await, model or servers; the examples are now complete. The Blocks page said the assembler orders blocks by priority: blocks keep the order you list them in, and priority only decides what is dropped under a token budget (new Token Budgeting section). It also said fill_slot() "returns a new assembler (immutable)": it updates the assembler in place and returns it, like add() and remove().
  • Deployment -- the CORSConfig table listed ["*"] defaults the code never had (origins default to none); it now shows the real defaults, and explains that browser clients need both allowed_origins and CORSConfig. The Docker healthcheck and Kubernetes probes use /health and /health/ready (the healthcheck no longer assumes curl, which python:*-slim images lack), the container example binds 0.0.0.0 with allowed_hosts, the startup policy log lines and their levels are listed, and new sections cover sessions across restarts and replicas (sticky sessions or stateless=True) and running under uvicorn or gunicorn with asgi_app().
  • Server configuration -- "http" and "streamable-http" were described as different transports; they are the same Streamable HTTP client ("streamable-http" is an alias), and "sse" is the legacy HTTP+SSE transport.
  • MCP client -- new section on reconnecting after a server restart; health://liveness/health://readiness resource names corrected in Resilience Patterns.
  • Engine docs: 18 node flags (not 16); agent_pattern= instead of the deprecated pattern=; agent.last_report instead of agent._inner.last_report; the custom-reasoning guide's inject_tools example, final-answer loop and LLM-routing example (which needs an output_schema) corrected; transition-resolution order matches the engine; PEOATR, runtime tool injection and serialization pages rewritten. Examples that didn't run fixed: graph.when(..., priority=) (no such argument: add_edge), LengthGuard(min=..., max=...) / (min_chars=...) (min_length/max_length), SchemaStrictGuard(Model) (takes no arguments), creative passed as a strategy (it is a perspective), engine.ainvoke(input), and missing imports in the custom-reasoning guide and node reference. New example: examples/reasoning/plan_act_verify.py.

  • RAG -- the guide now says that a RAGPipeline is one shared corpus that rag_to_tool() does not scope to the caller, and shows a per-caller tool that filters on get_current_caller() and refuses requests without a tenant (Who can retrieve what); the multi-tenant RAG post says its automatic scoping applies to memory providers, not RAGPipeline. Corrected: the chunker's default separators (no ""), the default chunker (chunk_size=500, overlap=50, not 1000/100), the VectorStore.search() parameter name (vector), the multi-store example (build_agent needs servers), the module docstring's search() signature (without filter, which the pipeline always passes), and the load() docstring (list(await self.iter_load()) can't work). New: updating documents, how InMemoryVectorStore scores, and the iter_load() loader method.

v1.2.1 — 2026-10-10

Fixed

  • MCP client: a rejected connection hung, then failed with a cancel-scope traceback -- connecting to an HTTP server that answers the handshake with a 4xx (most often 401 Unauthorized from a server built with require_auth=True when no or the wrong api_key/bearer_token is configured) left MCPClient, MCPMultiClient and build_agent() waiting, then crashed with RuntimeError: Attempted to exit cancel scope in a different task. Connecting now fails at once with a typed MCPConnectionRejectedError (a subclass of MCPClientError) carrying status_code, reason, url and server_name, and a message that names the server and says what to check: Server 'orders' rejected the connection: 401 Unauthorized. Check the bearer_token/api_key configured for it. The client now owns its transport in a task of its own, so other connection failures raise a readable MCPClientError too, never cancel the caller's task, and a client can be closed from any task. New example: examples/mcp/client_auth_errors.py.

  • MCP server SDK: tool parameters lost their descriptions -- @server.tool() resolved type hints without include_extras, so Annotated[str, Field(description=..., ge=..., pattern=...)] reached Pydantic as a bare str: the description and every constraint disappeared from inputSchema and were not enforced at call time. Annotated metadata is now kept, and a Field(...) used as the parameter default (order_id: str = Field(description=...)) works the same way (before, such a parameter silently became optional, with the FieldInfo object as its value). A parameter without a Field description takes it from the docstring: Google-style Args: / Arguments: / Parameters: entries and Sphinx :param name: fields are read, wrapped continuation lines are joined, and a Field description wins when both are given. Prompt arguments use the same parser, so their multi-line Args: entries are no longer cut after the first line.

Changed

  • MCP server SDK: the description is the docstring's summary paragraph -- tools, resources and prompts without description= used only the first docstring line, so a summary wrapped over two lines was cut mid-sentence. The description is now every line up to the first blank line, section header (Args:, Returns:, ...) or Sphinx field, joined with single spaces. Later paragraphs are still not sent: they are usually notes for maintainers, and the Args: section goes into the parameter schema. Pass description= for anything longer. Documented under Describing parameters.

v1.2.0 — 2026-10-10

Added

  • MCP: promptise mcpcast — turn an existing API into a curated, safe, agent-ready MCP server (promptise.mcpcast) -- point it at an OpenAPI 3.x / Swagger 2 spec (file path, URL, or inline JSON/YAML) and get a real, editable project out -- <name>-mcp/ with mcpcast.plan.yaml (the source of truth), the server as an installable <name>_mcp/ package (config.py, upstream.py for the HTTP client and credentials, approval.py for the gate, server.py with build_server(), one tools/<resource>.py module per resource; depends only on promptise + httpx), a server.py launcher (runs without installing; exposes a module-level server so promptise serve server:server works), a generated tests/ suite (every tool listed, routed to the right upstream operation on a fake upstream, and gated when it changes data -- pytest runs it), a README.md with ready-to-paste Claude Desktop, Claude Code, and Cursor snippets, and, written once and then yours, pyproject.toml (pip install -e . gives a <name>-mcp command and python -m <name>_mcp), Dockerfile, .env.example and .gitignore -- so any MCP-capable AI can use the API and the result can be reviewed, tested and shipped like any other service. The generated code is formatted to 100 columns with double quotes, block-style collections and wrapped descriptions, and lints clean under the project's own [tool.ruff]. A deterministic risk classifier labels every operation read / write / destructive / financial from its method and the verbs and money words in its id, path and summary (never the free-form description), then escalates one level for admin/root/superuser OAuth scopes, admin/internal/sudo/impersonate path segments, deprecated: true, and a GET that names a destructive verb (deprecated operations are always dropped). Safety profiles decide what is generated: read-only (default) emits reads only, standard adds writes, full adds destructive and financial operations -- and every non-read tool is emitted with requires_approval=True, enforced server-side by ApprovalGateMiddleware for any MCP client, deny-by-default on timeout. LLM curation (default on; --no-curate is the fully offline path) lets the model propose the tool budget, drops, collapses, renames, agent-audience descriptions, a parameter diet (hidden parameters sent as defaults), and examples, while the code enforces the post-conditions: at most --max-tools (25 by default), valid unique names, no unknown operations, no operation in more than one tool or both kept and dropped, hidden-required parameters need a default, deprecated operations must be dropped, and risk is never downgraded below the classifier (escalation only). Violations are fed back to the model for up to three attempts, then it fails loudly with MCPcastError -- no silent fallback (operations the model never mentions are dropped as "not selected by curation"; the profile is applied after curation). The plan file is the source of truth: edit mcpcast.plan.yaml and re-run promptise mcpcast mcpcast.plan.yaml to regenerate the package, launcher, tests and README next to it (the plan and the scaffold files are left untouched, comments included; tools modules the plan no longer produces are removed; a spec run into an existing project is refused without --force). Auth modes: passthrough (forwards the caller's Authorization header over HTTP/SSE; a missing one fails with UPSTREAM_AUTH_MISSING), env-token (one credential from MCPCAST_UPSTREAM_TOKEN on every call — the setup for a personal server launched over stdio by Claude Desktop, Claude Code or Cursor), api-key (MCPCAST_CLIENT_KEYS for callers, require_tenant=True, per-tenant upstream tokens from MCPCAST_UPSTREAM_TOKENS), and none (no credentials, loopback only). Approval modes: elicitation (asks the human behind the calling client; fail-closed without a live session) or pending (four-eyes review through generated, tenant-scoped approvals_list / approvals_decide tools for an approver role; only with api-key auth, since it needs identified callers). Upstream failures surface as structured ToolError codes (UPSTREAM_AUTH_MISSING, UPSTREAM_UNREACHABLE, UPSTREAM_ERROR with the status in details); path values must be a single real segment (no ..), nested form and deepObject query values are bracket-encoded, and operation-level servers are honoured per route. Generated code is injection-safe by construction: spec text only ever lands in string literals or comments. CLI flags: --review (show the kept/dropped plan and confirm), --serve (run the result immediately; -t stdio|http|sse), and --eval, which produces an Agent Readiness Score: a real build_agent() agent drives the generated server in-process via TestClient on generated tasks (one per tool); routes of read tools may hit the live API (decided by risk class, not HTTP method) while everything else hits spec-derived mocks behind an auto-approver, so an evaluation never changes real data; api-key and env-token servers get evaluation-only credentials for the run. The report grades A-F (0.6 * task success + 0.4 * correct-tool-selected-first) and lists confused tool pairs, parameter errors, tools a task needed but the agent never called, tools no task covered, and concrete fixes ("x vs y are ambiguous -- merge them", "no description and no example") in eval/report.md + eval/tasks.yaml. Curation and eval both call the model through build_agent(). Curation also repairs rather than rejects one class of model output: an example whose values contradict the API's declared schema ({sku, quantity} against a schema of {sku, qty}) is replaced with a spec-derived one, because a hint to the agent is not worth failing a run over. Public API in promptise.mcpcast: mcpcast(), curate(), build_plan(), write_project(), render_project(), load_generated_server(), evaluate(), MCPcastPlan (load/save), SafetyProfile, AuthMode, ApprovalMode, RiskClass. Docs: docs/mcp/server/mcpcast.md (guide) and docs/api/mcpcast.md (API reference); example: examples/mcp/mcpcast_petstore/.
  • MCPcast: a guided setup in the terminal (promptise mcpcast with no arguments) -- a full-screen, keyboard-driven wizard built with Textual (a new core dependency, pure Python) that walks through the seven decisions -- OpenAPI source (file, URL, or a detected API running on a local port), curation model or offline, safety profile with the exact tool counts each profile would generate from your spec, how the server will be used (mapped to the auth mode, with the environment variable you will set), name / folder / budget / evaluation, a review table with a per-tool detail panel and the never trust it blindly checklist, and the write step -- with the explanation each choice needs next to it. It runs the same promptise.mcpcast pipeline as the CLI, shows whether the model's key is picked up before anything is called, invalidates the plan when an earlier choice changes, and ends by printing the exact non-interactive command that reproduces the run. promptise mcpcast SPEC --interactive opens it pre-filled; without a terminal (CI, pipes) the command refuses with the non-interactive form to run instead. The review step also shows computed warnings — descriptions that name a tool the plan does not expose (the model writing "use delete_book" for an operation the profile excluded), and parameters hidden from the agent — available as review_warnings(plan). Public API: promptise.mcpcast.wizard (run_wizard, MCPcastWizard, detect_local_apis, review_warnings, equivalent_command). Docs: Guided Setup; lab: examples/mcp/mcpcast_wizard_lab/ — a real Helpdesk API, the wizard, then a real agent, the gate and the Agent Readiness Score against the live app.
  • Core: .env is loaded everywhere -- a .env file in the working directory (or a parent) is loaded before a provider's variables are checked, by plain scripts as well as the CLI, so python my_agent.py and promptise run see the same keys. An already-set environment variable always wins over the file; PROMPTISE_NO_DOTENV=1 disables loading. Every missing-credential error now names the three ways to supply it (.env, export, Model(..., api_key=)), and the new Configuration & Secrets page documents where keys live and the precedence between them.
  • Core: bring your own model — one install, any provider, credentials in code (promptise.models) -- every place Promptise takes a model now accepts a provider:model string, a Model(...) object with the same words for every provider (model, provider, deployment, api_key, endpoint, api_version, region, project, settings and extra), or the same fields in a .superagent file. No per-provider packages: OpenAI, Azure OpenAI and Anthropic use their native integrations (core), and Groq, Gemini, Vertex AI, Bedrock (with a Bedrock API key), Mistral, DeepSeek, xAI, Together, Fireworks, Cohere, OpenRouter, Perplexity, NVIDIA, Hugging Face, Ollama and the Azure AI Foundry catalog are reached through their OpenAI-compatible endpoints with langchain-openai (core) — Model(..., native=True) opts into a provider's own package when installed. Azure AI Foundry is first-class: provider="azure" with deployment= for OpenAI deployments, provider="foundry" for catalog models. A missing credential raises ModelSetupError naming the variable, where its value lives in the provider's console, and the three ways to supply it; a word that does not apply to a provider (api_key on Ollama, deployment outside Azure) is refused with what to use instead. New CLI: promptise models list|check [--ping]|env. Docs: Model Setup, Configuration & Secrets, API reference.

Changed

  • MCPcast generated projects pin their lint rules -- the generated pyproject.toml now declares [tool.ruff.lint] select (and target-version) instead of relying on ruff's defaults, which changed in 0.16 and made previously clean projects report TRY004/RUF100. A generated project lints the same whichever ruff its owner has, and the rule set is theirs to widen.
  • Maintenance for this release -- Python 3.13 joins the test matrix (and the package classifiers name 3.10-3.13); ruff is allowed up to 0.17; actions/setup-python and actions/github-script are current. The security job's dependency check used to be pip freeze | safety check --stdin || true, which could never fail: it now runs pip-audit over everything promptise[all] resolves to and fails the build, with .github/pip-audit-ignore.txt holding the advisories we knowingly tolerate (today: four ChromaDB server advisories with no fixed release — ChromaProvider embeds Chroma rather than exposing its HTTP server, which docs/core/memory.md now states).

  • Security gate: the CI environment's own build tools are kept current -- the pip-audit job audits the whole environment, so the runner's preinstalled setuptools 79 (PYSEC-2026-3447) failed the release gate although no Promptise dependency was affected; the job now upgrades setuptools (>= 83) alongside pip before auditing.

  • Packaging: SPDX license metadata -- license = "Apache-2.0" with license-files, replacing the table form and the license classifier that setuptools deprecates (builds would stop working in February 2027). The wheel now carries Metadata 2.4 with License-Expression: Apache-2.0; the build backend requires setuptools>=77.

  • Dependencies: httpx is now an explicit core dependency -- generated mcpcast servers (and the OpenAPI provider) import it directly, so it is declared as httpx>=0.27 instead of being relied on implicitly. It was already pulled in transitively by mcp and langchain-core, so a fresh install brings in nothing new.

Fixed

  • MCPcast generated servers: hardened after an adversarial audit -- an env-token server (one shared credential, no caller authentication) now binds loopback only, like none; --public on the generated command line and on promptise mcpcast --serve (or MCPCAST_PUBLIC=1) is the explicit opt-in for a gateway-fronted deployment, and the env-token Docker image serves stdio. passthrough requires a Bearer value and is documented as a relay that belongs behind an authenticating gateway; api-key refuses to start with an empty MCPCAST_CLIENT_KEYS. Credentials are validated when read (UPSTREAM_AUTH_INVALID names the variable, never the value), no upstream error message echoes a header, and a credential is never sent to a plain-http:// non-loopback host without MCPCAST_ALLOW_INSECURE_HTTP=1 (UPSTREAM_INSECURE). Responses are streamed and capped (MCPCAST_MAX_RESPONSE_BYTES, UPSTREAM_RESPONSE_TOO_LARGE), a JSON content type with a non-JSON body is a structured UPSTREAM_ERROR, 408/425/429 are retryable with retry_after, and the error-body excerpt is configurable (MCPCAST_ERROR_EXCERPT_CHARS). A multi-route tool whose arguments match no route fails with VALIDATION_ERROR naming the alternatives instead of guessing. MCPCAST_BASE_URL now overrides route-level hosts too, and routes only carry their own host when the operation declares one -- a FastAPI spec fetched from a URL no longer freezes that origin into every route. Newlines and control characters are rejected in base URLs (they could reach the Dockerfile and .env.example), spec_source is recorded without userinfo, query or fragment, the pyproject description escapes backslashes and quotes, lone surrogates and non-finite numbers cannot crash generation, README cells are escaped, generated tests call tools by the Python identifiers they expose, reserved and keyword operationIds are suffixed _op instead of dropped, tool groups avoid annotations and the package's own module names, regeneration only removes tools modules it generated, a non-empty output directory without a plan is refused without force, external or dangling $refs drop the operation with a reason, and fetched specs are capped (MCPCAST_MAX_SPEC_BYTES). The Dockerfile uses python:3.12-slim-bookworm, a non-root user and no baked-in configuration. Every generated project passes ruff check, ruff format --check, mypy and its own tests -- asserted in the test suite for all four auth modes.
  • MCPcast guided setup: hardened after an adversarial audit -- local API detection never follows redirects, never treats a response body as a path or URL (bodies are parsed strictly as JSON/YAML, capped at 5 MiB, alias bombs refused), stops after a ten-second budget and names the ports it skipped, and cannot be crashed by a malformed local service; spec loads are generation-numbered so a superseded load never lands and reloading an edited spec invalidates the plan; writing again after changing an answer really writes again; a folder that already has files in it (an mcpcast project or anything else) is refused without the overwrite switch (--force on the CLI), and ~ is expanded; credentials in a spec URL are used for the fetch only and never recorded, shown, copied or printed; the base URL typed after loading is the one every route uses; Ctrl+Q after a write reports the project instead of "Nothing written."; --model, --eval-tasks and --force pre-fill the wizard while --yes, --transport, --host and --port are refused with it like the other flags; user text is never interpreted as markup; every write failure is shown.
  • MCP server SDK: elicitation and sampling never reached the client -- Elicitor.ask() called session.send_elicitation_request(...), and Sampler.create_message() passed model=/system=, neither of which exists on the pinned mcp SDK's ServerSession (elicit(message, requestedSchema) and create_message(messages, max_tokens, system_prompt, model_preferences, …) are the real signatures); both errors were swallowed, so ElicitationApprover denied every gated call for every client and sampling always returned None. Both now call the real methods (a declined/cancelled/timed-out elicitation still denies, fail-closed, and is logged at WARNING), Elicitor.ask() honours its timeout, the tests mock the real method names, and SDK contract tests assert the signatures still bind -- a renamed SDK method fails loudly next time. An in-process mcp.ClientSession round-trip test drives the approval gate through real elicitation.
  • Engine: a failed model call no longer produces an "answer" -- when a run ended on a node whose model call failed (a rejected API key, a provider outage, an exhausted RETRYABLE node, a CRITICAL abort) ainvoke() returned {"messages": ...} with nothing but the user's question, so examples printed the question as the answer and the Agent Readiness evaluation graded a wrong key F with bogus advice. PromptGraphEngine.ainvoke() and astream_events() now raise GraphExecutionError (graph and node name, the ExecutionReport, the provider exception as __cause__); routing a failure to a handler node that succeeds, or a hook marking it recovered, still counts as recovery. The readiness report names crashed tasks first, excludes them from "never used", and evaluate() raises when no task could run.
  • Model no longer prints credentials -- repr()/str() of Model(..., api_key=...) showed the key (and extra), and a failed model_override wrote it into NodeResult.error, logs and graph history. Both fields are excluded from the representation and node errors name the model spec, not the object.
  • Readiness: an approval-gated route could reach the real API during an evaluation -- the evaluation transport ordered routes by the length of their regex and took the first match, so a templated read (GET /users/{id}, whose {id} becomes a 5-character pattern) was tried before an escalated route with a short literal segment (GET /users/wipe), and the gated call went live through the auto-approver with the real upstream credential. Routes are now ordered by specificity (literal segments before placeholders), and a request goes live only when every route it matches is a read; any other match is mocked.
  • Readiness: evaluating a project whose operator already configured MCPCAST_CLIENT_KEYS rejected every call -- the evaluation key and tenant are now merged into the existing MCPCAST_CLIENT_KEYS / MCPCAST_UPSTREAM_TOKENS for the run and every variable is restored afterwards; auth rejections get their own line in the fixes.
  • Approval gate: one caller could fill the shared pending queue -- PendingApprover(max_pending_per_client=...) bounds what a single client or tenant can park (identified by caller_user_id, then client_id/tenant_id metadata, then agent_id); generated MCPcast servers set it from MCPCAST_MAX_PENDING_PER_CLIENT (default 20).
  • .env loading: an exported-but-empty variable blocked the file's value -- non-empty values from .env now fill variables that are set to an empty string, and promptise models check says so; Vertex AI ADC failures name the google-auth reason instead of swallowing it.
  • Docs: the model-setup page advertised providers that did not work as written -- the provider table listed google:gemini-2.5-pro, which failed with LangChain's "Unable to infer model provider" (the LangChain prefix is google_genai; google: and gemini: are now registered aliases), and described Ollama as "no key needed" while langchain-ollama was neither installed nor declared (Ollama now goes through its OpenAI-compatible /v1 endpoint and needs nothing). anthropic: only worked when langchain-anthropic happened to be installed -- it was never a declared dependency; it is now a core dependency. Every model string on the page now resolves through the registry that promptise models check reports on.
  • CLI: promptise list-tools works again, and the REPLs exit cleanly -- list-tools crashed with 'PromptGraphEngine' object has no attribute 'tools'; PromptiseAgent now exposes tools and tool_names (every tool the model was bound to: MCP-discovered, extra_tools, sandbox, cross-agent and meta tools), and the command uses them. promptise run, promptise agent and list-tools now call agent.shutdown() in the task that opened the MCP sessions, which removes the RuntimeError: Attempted to exit cancel scope in a different task traceback that stdio servers produced on exit.
  • MCPcast docs: the architecture, drawn -- the end-to-end page opens with the runtime picture (assistant ⇄ generated server ⇄ your API, with the auth, gate, routing and upstream layers inside the server), shows the MCP contract and the life of one gated tools/call as sequence diagrams, the build pipeline as a flowchart and where the upstream credential comes from per auth mode; the reference draws the four deployment topologies and the guided-setup page the seven steps against their CLI flags.
  • MCP server SDK: caller identity is per request, and loopback servers validate Host/Origin -- under Streamable HTTP the handler read the headers of the request that initialized the session, so passthrough forwarded that first bearer token on every later call (even header-less ones) and, with api-key, anyone holding a valid key plus a leaked mcp-session-id ran as the tenant that opened the session, approver role included. Headers, X-Request-ID and the client address now come from the current message (bind_transport_request()), and with require_auth=True a session is bound to the credential that opened it (another credential gets 404 Session not found). The HTTP/SSE transports had no DNS-rebinding protection: a web page could drive a loopback-bound env-token/none server with the operator's credential. A loopback bind now validates Host/Origin against the loopback names (421/403 otherwise); MCPServer.run()/run_async() take allowed_hosts=/allowed_origins= (added to the loopback list; required to restrict a public bind) and promptise serve exposes them as --allowed-host/--allowed-origin. 36 tests over a real socket.
  • MCPcast generated servers: credential handling, hardened again -- a user:password@ in an operation-level servers URL bypassed the validator through model_copy() and landed in the plan, README, config.py (inside the instructions every client receives) and the tools modules; routes are validated now and such operations are dropped with a non-echoing reason. An upstream that quotes the Authorization header in an error body handed the server's own token to the MCP client through the error excerpt; the credential (raw, bare and URL-encoded) is replaced by [redacted] before the excerpt is cut, and non-JSON bodies are scrubbed too. .env.example shipped a working client key (sk-acme): it now ships an empty MCPCAST_CLIENT_KEYS with the shape as a comment and a mint command, and build_server() refuses the documentation keys and <placeholders>. MCPCAST_BASE_URL and base_url refuse a query string or fragment (route paths are appended and the value is copied into files). securitySchemes are honoured: an API keyed by apiKey in a custom header or query parameter gets credential_location/credential_name in the plan, the credential presented there at run time, and --auth passthrough refused with a pointer (it can only relay Authorization); operations that need a required header/cookie parameter the runtime cannot send are dropped with that reason. Generated launchers read .env next to server.py (as the file's header promised), GET bodies declared by the spec are sent, and array-of-object query parameters are indexed correctly (tags[0][a]=1&tags[1][a]=2).
  • MCPcast: plans and regeneration -- a plan file could downgrade a DELETE/POST tool to read (emitted un-gated with readOnlyHint): MCPcastPlan now refuses a risk below the deterministic floor of the tool's routes. Regenerating no longer overwrites a hand-written tools/<group>.py or deletes a user's copy of a generated module (refused without --force; ownership is by the module's own generated header, found even in modules with hundreds of tools), and a plan whose api.name changed is refused while the old package and scaffold would still be packaged and shipped. Spec examples of the wrong JSON type are coerced or replaced (the tool no longer rejects its own example), properties or examples with a key literally named $ref are no longer dereferenced, tool descriptions are capped at render time (8 000 characters, with the plan keeping the full text and the wizard warning), generated tests compare the encoded request path (format: email/date-time/uri examples pass), parameters named route/str/Any and identifiers over 56 characters type-check and format, and README cells escape plan text. Client snippets in the generated README match the auth mode: env-token carries the token in env, api-key/passthrough get HTTP configurations (HTTPServerSpec, claude mcp add --transport http … --header) instead of stdio ones that cannot work.
  • MCPcast guided setup and CLI: hardened again -- terminal escape sequences from spec or model text no longer reach the terminal (a hostile spec could blank or forge rows of the --review table, or write the clipboard through local API detection): control characters are removed at the parse boundary, refused in plan names, and scrubbed by every console renderer. Enter during a plan rebuild can no longer write the previous plan under new settings; review-pane links open only http(s) URLs; local API detection enforces the same depth/node caps as load_spec; Ctrl+Q no longer waits for a CPU-bound YAML parse (libyaml's parser with Python's composer, on a daemon thread); very large descriptions and dropped lists are clamped in the review pane; --serve --public is refused for auth modes that authenticate callers instead of crashing the generated entry point; an inline plan longer than the filesystem name limit regenerates; the printed next steps quote every path and carry the evaluation credential; ~ is expanded in the spec field; untitled inline specs get the same name in the wizard, the CLI and mcpcast(); a plain-http:// upstream is called out with MCPCAST_ALLOW_INSECURE_HTTP (and the generated tests opt in for those hosts); Agent Readiness live reads follow MCPCAST_BASE_URL and a request the evaluation has no mock for is a scored failure, never a fake 200.
  • .env discovery, hardened -- the walk skipped nothing: a world-writable or foreign-owned /tmp/.env could redirect an exported key, and an unreadable or non-UTF-8 .env up the tree crashed every promptise command at import. Files that are not regular, not owned by the current user or world-writable are skipped with a warning, the search stops at the project root (pyproject.toml/.git), an unreadable file is a ModelSetupError naming it, the CLI and promptise models check say which file filled a variable, Model(api_key="") counts as not given, and the Vertex AI token is refreshed before it expires instead of being pinned once.
  • Model resolution: one httpx client pair per model -- langchain-openai caches one process-wide client, whose pooled connections belong to the first event loop; the second asyncio.run() in a process (curation then evaluation, the guided setup then an agent run) failed with RuntimeError: Event loop is closed on the latest SDKs. Every resolved OpenAI-route model now gets its own clients (a caller-supplied http_client/http_async_client wins).
  • Shell hooks and the template shell executor spawn without fork() -- on macOS a forked child of a multithreaded process that has used system frameworks (any httpx client does) could die with SIGSEGV before exec (exited -11). Unless cwd is set, ShellHook and SubprocessShellExecutor now spawn through CPython's posix_spawn path (executable resolved on PATH, close_fds=False — descriptors are non-inheritable anyway), and shell=False command lines are tokenized like a terminal would.
  • MCPcast: a credential in the spec URL never reaches the project -- promptise mcpcast https://user:token@host/openapi.json?api_key=… used to derive base_url from the raw URL, so the token landed in mcpcast.plan.yaml, config.py (and the instructions every MCP client receives), .env.example and the README, and was echoed on stderr. The credential is now used for the download only (public_url() strips userinfo, query and fragment before anything is derived, printed or recorded; fetch errors are scrubbed), and ApiPlan.base_url / route hosts refuse user:password@ outright with a pointer to --auth env-token -- validation errors are rendered without pydantic's input_value (render_validation_errors()), so a refused value is never echoed either.
  • MCPcast: malformed specs fail with Error:, not a traceback; alias bombs are refused -- info or paths that are not mappings, documents nested too deeply (RecursionError, depth cap 256) and YAML alias bombs (MAX_DOCUMENT_NODES = 2 000 000, env MCPCAST_MAX_SPEC_NODES; expanded_nodes() counts without expanding) raise MCPcastError naming the source; per-operation defects (parameters that are not a list of mappings, a requestBody.content that is not a mapping) drop that operation with the reason recorded, and a numeric operationId or tags value is coerced. The wizard's detection shares the same budget. Downloads have a wall-clock deadline (MCPCAST_FETCH_SECONDS, default 60) and a cancellation hook (load_spec(cancelled=…)): Ctrl+Q in the guided setup abandons an in-flight download or probe within one chunk instead of waiting for the transfer to finish (57 s → 2 s against a trickling server).
  • MCPcast generated servers: upstream deadline and pending capacity -- MCPCAST_TIMEOUT is now a wall-clock deadline for the whole upstream call (connect, headers and body; new retryable UPSTREAM_TIMEOUT error, UPSTREAM_UNREACHABLE is connection/DNS/protocol only). PendingApprover(max_pending_per_tenant=…) caps what one tenant can park across all its API keys; generated api-key servers set MCPCAST_MAX_PENDING (100), MCPCAST_MAX_PENDING_PER_TENANT (40) and MCPCAST_MAX_PENDING_PER_CLIENT (20), and refuse to start when the caps do not nest. Generated code stays within 100 columns for names at their limits (14 resources, 59-character operation ids, 60-character parameters) -- asserted with ruff check --extend-select E501, ruff format --check, mypy and the generated suite in all four auth modes.
  • MCPcast: generated names are identical on every supported Python -- keyword handling used keyword.issoftkeyword(), which answers for the running interpreter (type is soft from 3.12 only), so a plan built on 3.10 named a tool type where 3.12 named it type_op and a tag type became a different module. A fixed soft-keyword set (SOFT_KEYWORDS, is_python_keyword()) is used for tool, parameter and module names.
  • MCPcast CLI: a plan given as a URL or inline text crashed on regeneration -- promptise mcpcast https://…/mcpcast.plan.yaml fetched the plan and then re-read it as a local path (FileNotFoundError traceback). The loaded document is validated directly (MCPcastPlan.from_document()), a plan without a directory of its own is written into ./<name>-mcp, and the plan file is included in that project.
  • Guided setup: the printed "next time" command and the evaluation default -- the command is now quoted for the shell it will be pasted into (double quotes on Windows, where cmd.exe takes single quotes literally; quote_argument()), and an explicit --eval-tasks 20 with -i is honoured instead of being replaced by the wizard's own default: the CLI, the wizard and evaluate() share one DEFAULT_EVAL_TASKS (20).
  • Pydantic deprecation warnings on import -- FallbackChain and the semantic-selection request_more_tools tool still used a class-based Config, so every import of promptise (including a generated MCPcast project's own test run) printed PydanticDeprecatedSince20; both use ConfigDict now, and a generated project's pytest run is warning-free.
  • MCPcast: nullable parameters are typed -- a FastAPI Optional[str] (anyOf: [{type: string}, {type: "null"}]) reached agents as an untyped author (object); the emitter now unwraps nullable anyOf/oneOf to the real type, and parameter descriptions are also read from the schema (where pydantic Field(description=...) puts them).

v1.1.1 — 2026-07-17

Fixed

  • Dependencies: cap mcp<2.0 -- mcp 2.0 changed the low-level Server() constructor signature and renamed ResourceTemplate.uriTemplate to uri_template, which breaks the MCP server SDK. With the previous uncapped mcp>=1.9.0, a fresh pip install promptise resolved to mcp 2.0 and the server SDK failed at runtime. Pinned to mcp>=1.9.0,<2.0 (validated against mcp 1.29.0 with the full suite green alongside the latest cryptography, starlette, langchain-openai, and pydantic). Support for the mcp 2.0 API is tracked as a follow-up.

v1.1.0 — 2026-07-08

Added

  • Identity: Agent Identity subsystem (promptise.identity) -- every agent gets a stable, traceable identity (who is acting), so its tool calls, audit entries, and outbound requests are attributable. An identity can be local (just an agent_id) or verifiable -- backed by a credential provider that mints a signed JWT the agent presents to the resources it calls. One class, AgentIdentity, with from_* factories and AgentIdentity.auto() platform auto-detection. Providers: Microsoft Entra ID, AWS IAM (STS + EKS), Google Cloud, SPIFFE/SPIRE, and a generic OIDC issuer -- with per-resource credentials, token caching, and declarative manifest config. Wired through build_agent(identity=...), cross-agent calls, MCP server auth + HMAC-chained audit, runtime, and observability. Cloud SDKs are optional per provider. Hardened: providers retry transient credential-acquisition failures (timeout/connection/429/5xx) but never a 4xx auth failure; server-side JWT verification tolerates a configurable clock-skew leeway; thread-safe caching is concurrency-tested. Easier: AgentIdentity/IdentityError re-exported from top-level promptise; laptop-runnable local and verifiable_mcp examples (no cloud/key); opt-in platform-gated live integration smoke tests. Docs: enterprise "why this matters", a provider decision guide, and an honest "verification status".
  • Engine: automatic context handling by default (context_scope="auto") -- the default ReAct agent (and therefore build_agent()) now bounds context automatically. It behaves exactly like "full" while a tool loop is short (zero change to simple tasks) and switches to the deduplicated facts-ledger once the loop passes auto_ledger_after (default 6) tool results, so deep tool loops stay token-efficient with no pattern to choose. An efficiency/context primitive, not an accuracy claim (use code-action for accurate aggregation over data).
  • Engine: code-action reasoning pattern -- build_agent(agent_pattern="code-action"). For aggregation / data-traversal tasks the model writes one Python program over your tools in a single LLM turn, instead of chaining dozens of conversational tool calls. The program runs in the hardened Docker sandbox (read-only rootfs, dropped capabilities, no network); its tool calls bridge back to the real host tools over a filesystem-RPC channel, where each tool keeps its protections -- approval gates if configured, plus budget/health/audit hooks when the Agent Runtime has attached them -- and the node enforces a hard per-run max_tool_calls cap regardless. The sandbox is auto-enabled (requires Docker), with bounded self-repair on a crash. Best with tools that return structured data (lists/dicts/numbers).
  • Engine: verify reasoning pattern -- build_agent(agent_pattern="verify") runs a single-pass self-verifying reasoning node (plan -> solve -> self-check -> final answer) at one-turn latency. It matches a plain prompt on models that already reason internally, and recovers errors a single pass would miss on weaker/mainstream models -- a good default when you want a self-checking answer without a multi-stage pipeline.
  • Engine: PromptNode(context_scope="scoped") -- context-lifecycle management for multi-stage reasoning graphs. A scoped node sees only its own working set -- its system prompt (carrying any inherited/distilled state), the task, and its in-progress tool loop -- not the verbose messages produced by other stages, so token usage does not grow super-linearly across stages. Opt-in; context_scope="full" (the default) preserves existing behavior.
  • Engine: managed tool pattern + PromptNode(context_scope="ledger") -- build_agent(agent_pattern="managed") runs a context-managed tool loop for deep multi-tool tasks. Instead of an ever-growing transcript (where the model loses track and re-queries the same facts), the node keeps a compact, deduplicated "facts gathered" ledger and serves identical (tool, args) calls from cache. It cuts redundant tool calls and bounds token growth at equal accuracy -- an efficiency/cost win for long chains (an efficiency primitive, not an accuracy claim).

  • MCP server: first-class multi-tenancy -- tenant_id as a structural isolation invariant. Server side: ClientContext.tenant_id from a configurable JWT claim or API-key config, tenant-qualified rate-limit buckets, tenant in audit entries, RequireTenant/HasTenant guards, MCPServer(require_tenant=True). Agent side: CallerContext.tenant_id + isolation_key (tenant::user) feeding cache scope keys, memory scoping, and conversation ownership -- two tenants with the same user_id can never see each other's data. SemanticCache.purge_user(user_id, tenant_id=...) purges exactly one tenant's scope.

  • MCP server: server-side approval gates (HITL) -- @server.tool(requires_approval=True) + ApprovalGateMiddleware enforce human approval for any MCP client. Fail-closed: denied on timeout by default, denied on modified_arguments, and an ungated declaration refuses to build. Guards run before a reviewer is asked, self-approval is rejected (four-eyes), and the flag survives router/mount composition. Approvers: PendingApprover (pending store + role-guarded approvals_list/approvals_decide admin tools), ElicitationApprover (asks the human behind the calling client; fail-closed without a live session), or any existing promptise.approval handler (shared ApprovalRequest/ApprovalDecision protocol).

Changed

  • Dependencies: eight Dependabot updates consolidated into this release and tested together, so the whole upgrade lands and verifies as one unit -- pydantic >=2.13.3, cryptography >=46, orjson >=3.11.8, numpy >=2.2.6, and the latest langchain / langchain-openai lines, plus GitHub Actions bumps (checkout v7, setup-python v6, codecov v7, codeql v4).
  • CI: verified against the latest majors a clean install resolves -- langchain 1.x (langchain-core 1.4.8), numpy 2.5, mypy 2.x, pytest 9.x -- the framework is runtime-compatible (full suite green) with no dependency caps. Compatibility fixes: dependency-resolution unblock (drop pyspiffe -> grpcio from the dev extra), an import-cycle break (cross_agent <-> observability), a numpy-2.x stub mypy override, and a widened on_llm_new_token override for langchain-core 1.x.
  • CI: cross-platform green -- two POSIX-only identity tests made platform-agnostic for Windows; the real-model ML-guardrail tests are skipped on macOS hosted CI only (gated on darwin && $CI), where the DeBERTa injection scores flap below the 0.85 threshold, and still run on Linux CI, Windows CI, and local macOS dev. The full 30-check matrix passes.

Fixed

  • Engine: routing-hint noise -- a linear node (a single default_next with no real branch) no longer receives a "choose the next step" instruction. The hint could be echoed as the answer by weaker models; it now appears only at genuine branch points (two or more distinct targets).
  • CLI: promptise serve now ships -- the documented deployment command was never registered in the CLI. It now resolves the module:attribute target, validates it is an MCPServer, and serves over stdio / HTTP / SSE with --dashboard and --reload support.
  • MCP server: declared per-tool rate limits are enforced -- @server.tool(rate_limit="100/min") was stored but never read. The spec is parsed at registration (malformed specs raise immediately) and enforced automatically -- per client when authenticated, a shared bucket otherwise -- raising RateLimitError with a retry_after_seconds hint. TestClient enforces the same contract. New public API: parse_rate_limit, DeclaredRateLimitMiddleware.
  • Core: CallerContext survives cross-agent delegation -- a peer invoked via ask_peer/broadcast overwrote the ambient caller with None, dropping the original user at every hop. PromptiseAgent.ainvoke now inherits the ambient CallerContext when no explicit caller is passed (an explicit caller= still wins), keeping peer cache/memory/guardrails/conversations attributed to the original human principal.
  • Docs: removed the phantom AgentAccessPolicy -- design docs referenced a class that never existed; replaced with the real layered access-control model (transport auth providers, per-tool guards, CallerContext, runtime open-mode guardrails).
  • Config: dict-form server specs carry every field -- a server passed as a raw dict (in runtime config and .agent manifests) no longer silently drops supported fields: HTTP keeps auth, stdio keeps cwd and keep_alive, so a dict config behaves identically to the equivalent HTTPServerSpec/StdioServerSpec. The runtime normalization is factored into a single _resolve_server_specs helper.
  • MCP client: stdio cwd is honored end-to-end -- StdioServerSpec(cwd=...) previously never reached the launched MCP subprocess (the native MCPClient had no cwd parameter), so the server always started in the parent's working directory. cwd is now plumbed through MCPClient into the SDK's subprocess launch, on both the agent and runtime paths.
  • Runtime: agent state history is an immutable snapshot -- AgentContext.put() (and initial state) recorded the live mutable value in its timestamped mutation history by reference, so a later in-place mutation retroactively rewrote past audit records. Each history entry is now an independent snapshot while the live blackboard value stays mutable.
  • Observability: cross-agent delegation stamping is snapshotted -- the delegated_by identity written onto a peer's timeline is now copied per entry, so a later mutation of one entry's metadata can't retroactively alter its siblings.

v0.6.1

Fixed

  • Runtime: cross-task MCP client issue -- AgentProcess._build_agent() now pre-discovers MCP tools before agent construction, resolving CancelledError when the MCP SDK's cancel scopes crossed asyncio task boundaries. Tools are pre-discovered and passed as extra_tools instead of relying on the session-bound MCP client.
  • Runtime: manifest server conversion -- manifest_to_process_config() now converts plain server dicts from .agent manifests into HTTPServerSpec/StdioServerSpec objects, fixing AttributeError: 'dict' object has no attribute 'transport' when starting processes from manifest files.

Added

  • Data Pipeline Monitoring Lab (examples/runtime/data_pipeline_lab/) -- 8 production examples demonstrating process lifecycle, triggers, journals, AgentContext, ConversationBuffer, multi-process AgentRuntime, manifest loading, and open mode with meta-tools. All examples use real LLM calls.
  • AI Content Creation Studio (examples/prompts/content_studio_lab/) -- 9 runnable demos covering prompt blocks, flows, guards, strategies, context providers, chain operators, registry, inspector, and templates. All demos use real LLM calls.

v0.6.0

Renamed package from deepmcpagent to promptise.

Added

  • Prompt Engineering framework -- 2-layer system for composing production prompts
    • Layer 1: PromptBlocks -- composable blocks with priority-based assembly
    • Layer 2: ConversationFlow -- turn-aware prompt evolution across conversation phases
    • PromptInspector for full prompt tracing and debugging
  • Agent Runtime -- lifecycle container for autonomous agent processes
    • AgentProcess with state machine (CREATED, RUNNING, SUSPENDED, STOPPED, FAILED)
    • Triggers: CronTrigger, WebhookTrigger, FileWatchTrigger, EventTrigger, MessageTrigger
    • AgentContext for unified state, environment, and file mounts
    • Journal and ReplayEngine for crash recovery
    • .agent YAML manifest format for declarative process definition
    • AgentRuntime multi-process manager with distributed coordination
    • CLI: promptise runtime validate|start|logs|init
  • MCP Server framework -- build production MCP servers
    • MCPServer with @server.tool() decorator and Pydantic model validation
    • MCPRouter for grouping tools under shared prefixes and policies
    • AuthMiddleware with JWTAuth and role-based guards
    • LoggingMiddleware, TimeoutMiddleware, ConcurrencyLimiter
    • BackgroundTasks via dependency injection
    • @cached decorator with InMemoryCache backend
    • ServerSettings for typed, environment-backed configuration
    • Exception handlers for structured MCP error responses
    • Lifecycle hooks (on_startup, on_shutdown)
    • Live monitoring dashboard
    • require_auth option for fully authenticated servers
  • MCP Client library
    • MCPClient for single-server connections with fetch_token() helper
    • MCPMultiClient for multi-server routing with automatic tool discovery
    • MCPToolAdapter for converting MCP tools to LangChain BaseTool instances
    • Tracing callbacks (on_before, on_after, on_error)

Changed

  • Package name: deepmcpagent renamed to promptise
  • CLI command: deepmcpagent renamed to promptise
  • All import paths: from deepmcpagent changed to from promptise

v0.5.0

Initial release as deepmcpagent.

Added

  • Core agent building with build_agent()
  • MCP integration with HTTPServerSpec and StdioServerSpec
  • Cross-agent communication and delegation via CrossAgent
  • SuperAgent .superagent YAML configuration format
  • CLI: deepmcpagent agent|validate|init|list-tools
  • Sandbox execution with SandboxConfig