Repository navigation
v1.3.3
A patch release with fixes found while writing and running the promptise.com guides. Upgrade with pip install -U promptise.
Highlights: the agent runtime's mission judge, max_invocations, health actions, journal API, live instruction updates and MCP client cleanup now work; explicit server credentials (API keys) win over agent identity and forwarded caller tokens; standard RFC 6749 token requests; the debate, deliberate, research and autonomous reasoning patterns end with a real answer; code-action's tool-call budget counts every call and fails loudly, MCP results arrive decoded, and sandbox cleanup takes ~0.1 s; open-mode tools take their declared parameters. Read Changed before upgrading.
Fixed
- The mission LLM judge always failed --
MissionTrackerbuilt its judge withbuild_agent()without the requiredserversargument, so every evaluation raisedTypeErrorbefore any model call and was recorded asachieved=False, confidence=0.5, reasoning="Evaluation failed: build_agent() missing 1 required keyword-only argument: 'servers'". With the defaultconfidence_threshold(0.7) the mission went topausedafter its first evaluation, evaluations stopped, andauto_completecould 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 asmodel. MissionConfig.max_invocationswas never enforced -- a mission ran past its limit indefinitely. The mission now fails once it reachesmax_invocationssuccessful invocations: right after the last one (once its evaluation has run, so a mission completed by that run is not failed), with amission.failedevent whosereasonis"max_invocations", and later invocations are skipped, as fortimeout_hours.- Health
on_anomalywas skipped unless the anomaly was less than 5 s old when the run ended, and never applied to a run that raised --AgentProcessacted 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, nohealth.anomalyevent. A stuck loop that ended inGraphIterationLimitError(or any error) never reached the check, and ahigh_error_rateanomaly 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_rateis 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 ownhealth.anomalyevent; the action runs once per run, for the latest. GET /api/v1/processes/{name}/journalwas always empty for processes with the defaultprocess_id-- the endpoint read entries byprocess_idwhile the process files them under its name (so they survive a restart), andAgentRuntime.add_process()gives a random UUIDprocess_id. It now reads by name.PATCH /api/v1/processes/{name}/instructionschanged 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 newAgentProcess.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-stringinstructionsvalue is refused with422 INVALID_CONFIG.AgentProcessnever closed its MCP client, so every stop, restart and hot reload leaked the previous connection and its stdio servers -- the process opens its ownMCPMultiClientfor the servers in its config, and nothing closed it: eachstop()/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=...)(andAgentProcess) presented the identity's bearer to every server without abearer_token, including one configured withHTTPServerSpec(api_key=...). The client sentAuthorization: Bearer <identity token>next tox-api-key, the server's auth gate checked the bearer first, and the connection failed with401, 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 anAuthorization/x-api-keyheader inheaders. - 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-encodedclient_credentialsrequest with400, andfetch_token()posted JSON withoutgrant_type, so it failed against a standard token endpoint. The endpoint now acceptsapplication/x-www-form-urlencodedrequests and HTTP Basic client authentication, answers errors with the standarderror_description(messageis kept), and sendsCache-Control: no-store. JSON bodies are still accepted.fetch_token()sends a form-encodedgrant_type=client_credentialsrequest, 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 --strictno longer prints "OK ... intact." when it fails -- with only warnings (a truncated last line, say), the summary saidOKwhile the exit code was1. It now readsFAILED ... no tampering found, but N warning(s) fail the check under --strict, and--jsonaddsstrictandpassed(the exit code's verdict) next took(the chain's verdict).- A built-in identity guard implies
auth=True--guards=[RequireAuth()](orHasRole,HasScope,RequireClientId, the tenant guards) on a tool withoutauth=Truedenied every caller, valid credentials or not, becauseAuthMiddlewareauthenticates onlyauth=Truetools. Such a guard now impliesauth=True, asroles=[...]already did, on tools, resources, prompts and router-level guards. Custom guards are unchanged. - Custom auth providers can hand their claims to
AuthMiddleware-- the documentedctx.state["jwt_payload"] = claimswas ignored (AuthMiddlewareread only the built-in providers' private_jwt_payload), so roles, scopes and tenant from a custom provider never reachedctx.client, androles=[...]denied every call.ctx.state["jwt_payload"]is now the public key for a custom provider's verified claims. MCPClientno longer triggers the MCP SDK'sDeprecationWarning-- with mcp 1.30 every HTTP connection warnedUse streamable_http_client instead.The client now usesstreamable_http_clientwhen the installed SDK has it and falls back tostreamablehttp_clienton older SDKs (down to the minimum, mcp 1.9).- Per-client rate limits that collapse into one shared bucket are reported --
RateLimitMiddlewarekeys its buckets byctx.client_id, which is set only whenAuthMiddlewareruns before it and the tool is authenticated; otherwise every caller silently shared one"global"bucket, and one runaway client throttled all the others. When anAuthMiddlewareis installed but cannot identify the caller for the limiter (it runs after the limiter, or the tool is notauth=True), the server now logs a warning naming the tools, once, at startup (and onTestClient's first call). Akey_funcmarks the key as deliberate and is not reported. A limiter without a synchronousconsume(key)method now raisesTypeErrorwhenRateLimitMiddlewareis created, instead of failing every call withINTERNAL_ERROR. - A forwarded caller token no longer overrides a server's
api_key--forward_caller_tokenis on by default, so during an invocation whoseCallerContextcarries abearer_token, every tool call (andread_resource/get_prompt) to a server configured withHTTPServerSpec(api_key=...)or anx-api-keyheader went over a session that sentAuthorization: Bearer <caller token>next tox-api-key. The server's auth gate checked the bearer first and answered401, 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 isHTTPServerSpec.forwards_caller_token. - A
.agentmanifest droppedforward_caller_token--manifest_to_process_config()did not carry it to theHTTPServerSpec, so a manifest could not opt a server out of receiving caller tokens. It is carried now, asAgentProcess'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 (nooutput_schema), so the critic's severity never sent the proposer back (it had noreviseroute either), the judge'son_failnever fired, and the run went proposer → critic → judge once and replied with the judge's text ("1) Does it pass ALL criteria? False ...").build_agentalso built it without the agent's tools. Critic and judge now return structured output (DebateCritique,DebateVerdictinpromptise.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 newanswerstage writes the reply. Every stage has a budget (critic and judgemax_rounds, proposermax_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'scontinue/replan/answerroute 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, actmax_act_steps, reflect 4), and a newanswerstage writes the reply.agent_pattern="research": the verify step could not fail -- it was aGuardNodewith 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 aResearchVerdict(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 mostmax_verify_roundstimes (default 2), then the run goes tosynthesize. 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 tosynthesizewhen 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 itsmax_iterations; the defaultreasonstep 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 (synthesizein the default pool, or the newAutonomousNode(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").AutonomousNodenow streams its steps' model chunks and tool events, soastream_with_tools()returns the answer (itsDoneEventwas empty).PromptGraph.peoatr()/.research()passed tobuild_agentas a graph ran as a node pool -- they were built in the default"autonomous"mode with no edges, sobuild_agent(agent_pattern=PromptGraph.peoatr(...))wrapped their nodes in anAutonomousNodeinstead of running the graph. They are now static graphs, like the other built-in patterns.- Code-action:
max_tool_callsdid 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 undermax_tool_calls=10made all 120). Every call a program makes now counts: successful, failed, an unknown tool, or one apre_toolhook blocked. - Code-action: reaching
max_tool_callswas 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.errorNone); 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 asNone spent $0.00). Now a call beyond the cap raisesToolBudgetExceededin the program (aRuntimeError, 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 thanmax_tool_callscalls 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_callscalls. With no repair left the run raisesGraphExecutionError, andExecutionReport.errornamesmax_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 astr), also behind the approval, guardrail and tracing wrappers. A Promptise error envelope ({"error": {"code": ..., "message": ...}}) raises in the program as anMCPToolError, like an MCP error result. Python tools are unchanged: astrthey return stays astr. - Code-action:
ExecutionReport.tool_callswas always 0 --CodeActionNoderecorded 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) ignoresSIGTERM, so every cleanup waited outdocker stop's 5 s timeout, blocking the event loop. Code-action paid it once per program and once more forbuild_agent's probe. A non-persistent session's container is now killed (newSandboxBackend.kill_container(), which defaults tostop_container()) and removed, about 0.1 s; a code-action program with no tool calls takes about 1 s in all. The Docker calls instop_container()/remove_container()no longer block the event loop. - Open mode:
create_toolignored itsparameters-- every agent-written tool took oneinputstring, so a tool written asdef run(a, b)failed withrun() 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 raiseToolArgumentError), andrungets them as keyword arguments, as plain JSON, with each property's schemadefaultfor one left out. Aparametersthat is not an object schema (or lists parameters withoutproperties, or names one that is not a valid Python keyword argument) is refused with an error and no tool is created. Emptyparameterskeep the free-forminputstring. - Open mode: a change refused by
max_rebuildswas 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 nextstart()(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_toolandconnect_mcp_serveranswerError: max rebuilds (N) reached; the change was not applied(create_toolandconnect_mcp_serverused to say the tool was created or the server connected). A change whose rebuild fails is undone too (a failedcreate_toolleft 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_everydefaults to1, 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",violationas an object withlimit_name,limit_value,current_value,tool_name) (budget.md);on_anomalyaccepts"log","pause"and"escalate"(the docs listed"stop", whichHealthConfigrejects), when the action runs, error-rate detection counts failed invocations, and theHealthMonitormethod 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 aJWTAuthparameter), relied on a guard withoutauth=True, and made a token withoutexp; it now runs, and the test suite runs it. - auth-security.md "Custom auth provider": stored the claims under a key
AuthMiddlewaredid not read; it now shows thejwt_payloadcontract and runs. - caching-performance.md "Tiered rate limiting": defined
async check(key, ctx), whichRateLimitMiddlewarenever calls (every call failed withINTERNAL_ERROR); it now usesconsume(key)with akey_functhat 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 andRateLimitMiddleware(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(orx-api-keyheader) wins over the agent identity, likebearer_token. - multi-user-systems.md and secure-multi-tenant-platform.md:
AuditMiddleware(secret=...)raisedTypeError(the parameter ishmac_secret), andJWTAuth,AuditMiddlewareandPostgresConversationStorewere given"${JWT_SECRET}"-style strings, which these classes take literally (only.superagentfiles and a few documented parameters expand them). The examples read the keys from the environment withos.environ[...]and the audit examples writeaudit.jsonl, verifiable withpromptise audit verify. The 30-tool server in multi-user-systems.md also usedMCPServer(auth=...),RateLimitMiddleware(requests_per_second=...)andserver.mount(router), none of which exist, and anon_authenticatehook 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 withinclude_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_tokenunset,TrueorFalse, 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
answerstage, deliberate's routes andanswerstage, 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 (guide, engine-prebuilts.md, reasoning-patterns.md, building-agents.md): the tool-call budget, MCP results arriving as data, and the latency per program (about 1 s, not 1-2 s). Open mode (
docs/runtime/meta-tools.md,configuration.md): howcreate_toolusesparameters, and whatmax_rebuildscounts and refuses. Sandbox (docs/core/sandbox.md):cleanup()kills the container, andkill_container().
Deprecated
MissionConfig.on_complete-- it was documented ("stop","continue"or"suspend") but never read: a completed mission stopped the process wheneverauto_complete=True, whateveron_completesaid. Setting it now emits aFutureWarning; it will be removed. Useauto_complete(Falsekeeps 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_ERRORwhere it gotACCESS_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 toclient_credentials. HTTPServerSpec.forward_caller_tokendefaults toNone: 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 setsforward_caller_token=True;TrueandFalsebehave as before.PromptGraph.debate()has a fourth node,answer, and new parameterstools,blocksandmax_proposer_steps;max_roundsnow 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 parametersblocksandmax_act_steps.PromptGraph.research()takesmax_search_stepsandmax_verify_rounds, and itsverifynode is aValidateNode(a model call per check) instead of aGuardNode; passverify=Falseto skip it.PromptGraph.autonomous()takesblocksandmax_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_callscaps each program, not the whole run: a repaired program gets a fresh budget (a run makes at most(max_repairs + 1) × max_tool_callscalls), 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 oncemax_rebuildsis 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
parameterstakes those parameters instead ofinput.
Full changelog: https://github.com/promptise-com/foundry/blob/main/CHANGELOG.md