Repository navigation
Promptise Foundry 1.5.0
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
messageand anerror_id; the exception and its traceback are logged onpromptise.runtime.apiunder that id. Pydantic validation errors still list each field. Code that parsed the exception text out ofmessageshould use thecode(andOrchestrationAPIError.error_idto 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,ToolCostAnnotationandDistributedConfigstill ignore unknown keys but emit aFutureWarningnaming them; a future release will reject them. ATriggerConfigoption that belongs to another trigger type (watch_pathon acrontrigger) 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 cleanstop()writes a checkpoint too. Setrecover_on_start=Falseto always start fresh. - Conversation and memory survive
stop()/start().stop()no longer clears the conversation history or closes the memory provider;close()does. SetContextConfig.conversation_clear_on_stop=Truefor the old behavior. SecretScopeConfig.revoke_on_stopis honored. Withrevoke_on_stop=False, secrets now survive a stop (every stop revoked them before).secretsvalues areSecretStr;${VAR}references are resolved when the process starts, not when the config is built, and literal values are masked inrepr()and JSON dumps.- Health
on_anomaly="escalate"always suspends the process, and sends the notification only whenescalationis 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_REJECTEDover 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(was409 DEPLOY_FAILED);DEPLOY_FAILEDis now422for a valid config that could not be created. Rotating an unknown secret is404 SECRET_NOT_FOUND(was404 PROCESS_NOT_FOUND). An oversized request body is413 PAYLOAD_TOO_LARGE, an unexpected error500 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 with403 COMMANDS_NOT_ALLOWEDunless the API is created withOrchestrationAPI(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 .../triggersorPATCHofbudget/health/missionwhose body contains a${VAR}reference (outsideinstructions) answers403 ENV_REFERENCES_NOT_ALLOWED. Allow specific variables withOrchestrationAPI(allowed_env_vars={...})(--allow-env-var NAME, repeatable) or all of them withallow_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_directoryand file-watchwatch_pathvalues in deploys andPOST .../triggersmust lie insideOrchestrationAPI(data_dir=...)(default./.promptise); others, and paths with..or~, answer403 PATH_NOT_ALLOWED. The deployed agent's ownadd_triggerwatch 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_serverrefuses hosts that resolve to loopback, private, link-local or other internal addresses (list such URLs inallowed_mcp_urlsto 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 newopen_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-loopbackwebhook_hostis refused unless the newopen_mode.allow_public_webhooksis on (and then it needs anhmac_secret). - Unsigned loopback webhooks refuse browser requests. A webhook trigger without
hmac_secreton a loopback address answers421for a non-loopbackHostheader and403for a cross-siteOrigin, like the control API. Local clients that send neither are unaffected. - Spawned processes inherit the parent's hooks. The parent's
hooksmanager is dispatched for every process it spawns (after the child's own hooks), so itspermission_requestand other hooks apply to children. ContextConfig(env_prefix="")is refused (an empty prefix exposed the whole environment).promptise runtime startremovesPROMPTISE_RUNTIME_TOKENfrom its environment after reading it, so processes, hooks and stdio MCP servers it starts no longer inherit the control API token.POST .../startand.../restartof a process whose start raised aKeyError(such as a missing secret) answer500 START_FAILED/RESTART_FAILEDinstead of404 PROCESS_NOT_FOUND.- Trigger IDs are stable. Config triggers get IDs of the form
<type>-<digest>instead of position-basedstatic_<n>. The old IDs are still accepted by the API trigger routes, with a deprecation warning in the response. DEFAULT_PRODUCTION_CONFIGno longer setsdistributed.enabled=True. The flag did nothing before; it now starts a network transport, so the preset leaves it off and is identical toDEFAULT_DEVELOPMENT_CONFIG.DistributedConfig(discovery_method="multicast")is rejected (it was never implemented).ShellHookgets a minimal environment (no inherited credentials) unlessinherit_env=Trueorenv_passthroughis set, and runs without a shell.- The budget daily reset honors
daily_reset_hour_utc; the counters no longer also reset at midnight UTC. StateErrorsubclassesProcessStateError(it is still aValueError), so all threeexceptclauses catch an invalid transition.Mem0Provider.close()keeps stored memories. It called Mem0'sMemory.reset(), which deleted every memory.
Fixed
AgentProcess.stop()could hang on Python 3.10 and 3.11 when a hook was running -- hook timeouts usedasyncio.wait_for, which before Python 3.12 can swallow the caller's cancellation, so a worker cancelled bystop()kept running. Hook timeouts no longer use it, andstop()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. Whenstop()(orAgentRuntime.stop_all()) cancelled that worker during the close,MCPMultiClientswallowed theCancelledError, the worker went back to waiting for events, andstop()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 undersuppress(CancelledError), so cancelling a stop that was waiting for them was ignored. It now waits withgather(..., return_exceptions=True); the foregroundpromptise runtime startshutdown waits for its signal waiter the same way.PostgresConversationStorefailed on first use -- thesessionstable DDL had an unescapedDEFAULT '{}', sostr.format()raisedIndexErrorwhile creating the tables and every Postgres-backed conversation store failed on its first call. The default is now escaped like themessagestable's.retry(prompt, max_retries=-1)raisedTypeError: exceptions must derive from BaseException-- it ran no attempt and then raisedNone. A negativemax_retriesnow raisesValueErrorwhen the retry is built, and_Fallbackrefuses an empty prompt list the same way. Retries and fallbacks re-raise the last error as before.- Restarting, starting or removing a
FAILEDprocess 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()andPOST /restart). They are now torn down first. stop()fromCREATEDraisedStateErrorand fromFAILEDnever reachedSTOPPED-- both now stop cleanly and emitprocess.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.resumedwithreason="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.failedhad an emptyerrorafter repeated failures -- it carries the real error now.- Webhook bodies over 1 MiB got
500-- they get413(checked againstContent-Lengthand while reading chunked bodies). A request that fails with500or503releases 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_eventsdifferent files are waiting is a new one dropped, with an error log and a count inFileWatchTrigger.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()(andDELETE /health/anomalies) now resets the cooldowns, and the anomaly history is bounded (HealthMonitor.MAX_ANOMALIES, 1000).on_anomaly="stop"is accepted byHealthConfig(it was unreachable). - Mission:
success_checkcould not be used from a process --MissionConfig.success_checkpasses it to the tracker. A completed or failed mission no longer becomes active again after a restart (it is restored from the journal).check_missionreturns the evaluation history, not only the last one.POST /mission/failstores the reason and emitsmission.failed. - Secrets:
get_secretresults were written to alevel="full"journal in plain text (see Security). Rotating an unknown secret returnedPROCESS_NOT_FOUND; the client'srotate_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 /messageschecks the process state likesend_message()(409 PROCESS_NOT_RUNNING). An/askcall whose question is removed byDELETE /inboxor evicted from a full inbox answers410 QUESTION_REMOVED(it ended with a closed connection or500);process.ask()raisesInboxQuestionRemovedorInboxFullError. - Context:
PATCH /contextwrote the allowed keys before answering403-- it is atomic now: nothing is written when any key is refused. Context updates are journaled (context_updateentries) and replayed on recovery. - Manifests:
context: initial_state:was silently dropped -- everycontext:key now sets theContextConfigfield of the same name (world:still setsinitial_state). - API:
PATCHofbudget,healthormissionwithenabled: trueanswered200and 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()andclear_inbox()raisedJSONDecodeErrorafter succeeding --204responses are handled.- Memory providers broke after a restart (
ChromaProvider is closed) -- the provider is created once and closed only byclose(). - 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,statusandrestartwere placeholders, and--detachjust exited -- see Added.promptise runtime startalso shuts down cleanly onSIGTERM(docker stopused to skipstop_all()).- Escalation failures were silent -- a webhook answering with an HTTP error status, a failed delivery, an
event_typewithout 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 --
AgentProcessdispatches all 13HookEvents. Declare them withProcessConfig.hooks(HookConfig: an asynccallback, or acommandrun byShellHook), in a manifest'shooks:section (commands only), or withprocess.hooks.on(...). Hooks havepriority,once,timeout(5 s for a command, 30 s for a callback),fail_closedandtag; blockable events can be refused withHookBlocked, and a hook can return data (for example a rewritten prompt).ShellHookpasses the event as JSON on stdin, runs without a shell, gets a minimal environment plusenv/env_passthrough, and is killed beyondmax_output_bytes. See docs/runtime/hooks.md. - Rewind --
AgentProcess.rewind_plan(entry_id, mode)andrewind(...)restore the conversation, the context state or both to a journal entry (modesboth,conversation_only,code_only,summarize), emittingprocess.rewound. API:GET /api/v1/processes/{name}/rewind/planandPOST /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 --
DistributedConfigis read: withenabled=True,AgentRuntime.start_all()/run_forever()start aClusterNodethat runs the management transport and either hosts the coordinator (coordinator=True, withdiscovery_method="registry"or"static"andstatic_nodes) or registers with one (coordinator_url), re-registering everyheartbeat_interval. New fields:coordinator,node_id,transport_host(default loopback),advertise_url,auth_token(one cluster token, required off loopback unlessallow_unauthenticated=True),static_nodes,node_timeout. See docs/runtime/distributed/. promptise runtimecontrol --startserves 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, thenSIGTERM,--force,--timeout) andrestartfind it there or use--url/--token($PROMPTISE_RUNTIME_TOKEN) for a remote API, and-p/--processacts on one process.start --detachruns the runtime in the background with a log file;starthas--runtime-name,--api-hostand--api-port.logsshows the newest entries with their entry IDs, and--followworks.- Process API --
add_trigger()/remove_trigger()on a running process,apply_governance_config(section),fail_mission(reason),record_operator_action(),suspend_reason,recovered, andstatus()now includes the health summary and file-watchpending_events/dropped_events. - New events --
process.suspended(reason:manual,idle,budgetorhealth),process.resumed(reason,suspended_for),process.restart_exhaustedandprocess.rewound. AgentRuntime.run_forever()-- starts every process and serves untilSIGTERM/SIGINT(orrequest_shutdown()), then shuts down cleanly.AgentRuntime(config)now registers the processes inconfig.processes(onlyfrom_config()did).- Webhook trigger --
TriggerConfig.webhook_max_body_bytes(default 1 MiB). - Security settings --
OrchestrationAPI(allow_env_references=, allowed_env_vars=, data_dir=)andpromptise runtime start --allow-env-references/--allow-env-var NAME(passed on to--detachruntimes);OpenModeConfig.identity_mcp_urlsandallow_public_webhooks;DistributedConfig.allowed_node_hostsandRuntimeTransport(allowed_node_hosts=);AgentRuntime.add_process(deploy_policy=)andSecretScope(source=, env_references=);SecretResolutionError(aKeyError). New API error codesENV_REFERENCES_NOT_ALLOWEDandPATH_NOT_ALLOWED(both403). - Budget --
BudgetConfig.warning_threshold(default0.8): the fraction of a limit at whichbudget.warningis emitted (it was fixed at 80%). - File-watch trigger --
watch_backend(auto,watchdogorpolling),watch_recursive(defaultTrue),watch_poll_interval(default 1 s) andwatch_max_pending_events(default 10,000). - Health --
HealthMonitor.apply_config(),clear_anomalies()returning the number cleared, andhealth_status()(public, JSON-safe). - Inbox -- refusal exceptions
InvalidInboxMessage,InboxRateLimitError,InboxFullError,InboxMessageRejectedandInboxQuestionRemoved(allValueErrorsubclasses),MessageInbox(scanner=...),remove_consumed(),prompt_questions(),parse_answers()andscan_inbox_message(). - Mission --
MissionTracker.fail(reason, source=),restore(), finalcompleted/failedstates and an evaluation history. - Secrets --
SecretScope.rotate(name, value, ttl=),list_metadata()(name, active, source, TTL and expiry; never the value),sanitize(), andSecretNotFoundError(aKeyError). - Context --
ContextConfig.state_toolgives the agent anupdate_state(key, value)tool (off by default; limited towritable_keyswhen set) andContextConfig.conversation_clear_on_stop. JournalConfig.recover_on_start(defaultTrue) andRedactingJournal.- Control API --
OrchestrationAPI(on_shutdown=)addsPOST /api/v1/runtime/shutdown;OrchestrationClient.shutdown();OrchestrationAPIError(a subclass ofhttpx.HTTPStatusErrorwith the API's errorcode), exported frompromptise.runtime;ERROR_CODESlists every code; config updates answer withliveandeffective_from(for example"next_invocation","next_budget_check","restart"or"redeploy") so a change that only applies later says so.
Changed
ProcessStateErrortakes an optional keywordmessage;StateErrorpasses its message through it instead of setting the inherited attributes itself and skippingProcessStateError.__init__. The attributes and messages are unchanged.- The core approval types live in a private module --
ApprovalRequest,ApprovalDecision,ApprovalHandler,ApprovalRuleandClassifierDecisionTraceare defined inpromptise._approval_types, sopromptise.approvalandpromptise.approval_classifierno 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 resolvestrace(it raisedNameError). - Silent
exceptblocks -- the ones that hide a real failure now log it: a failing cache-purge notifier,strategy.parseor context-block render in a node, anon_fallbackhook, a mission journal write (allDEBUG), 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 inPromptiseSecurityScanner's composable (detectors=) setup,@nodepassing its docstring description toBaseNodeinstead 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. TriggerConfigvalidation names the field and the fix:watch_backend="watchdog"without the package installed is an error at construction, andInboxConfigrejects adefault_ttllonger thanmax_ttl.EscalationTarget.webhook_urlmust be anhttp://orhttps://URL.- Every mutating API request is logged to the
promptise.runtime.apilogger (method, route, status, client; never the body) and, when it succeeds and the process has a journal, recorded as anapi_requestjournal entry. - HTTP MCP server credential headers (
Authorization,x-api-key, names containingtokenorsecret, ...) are masked inrepr()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 file0o644, so every local user could read which client called which tool (and the arguments withinclude_args=True). A new file is now created0o600. 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 600it. (CodeQLpy/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.remoteas 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 helperpromptise._logsafe.log_safe). (CodeQLpy/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 throughlog_safelike the webhook trigger's. A failed secret rotation no longer logs the secret's name: the warning carries the process, the exception type and theerror_idthe client gets (the audit line still records the route). (CodeQLpy/log-injection,py/clear-text-logging-sensitive-data) ${VAR}references are found in linear time --ENV_VAR_PATTERNbacktracks 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()(andhas_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_PATTERNis unchanged and still exported. (CodeQLpy/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_secretresults are no longer journaled atlevel="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 withallow_tool_creationnow counts as such a deploy:create_toolruns 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
/askanswer,process.ask()result orGET /inboxlisting 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_charsbudget could reach the prompt unscanned. - API-chosen journal, memory and watch paths are confined to
data_dir;env_prefixcan't be empty;GET /triggersmasks credential-likecustom_configvalues; 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 a500), and a registry coordinator can limit the hosts nodes register with (allowed_node_hosts); the docs describe why registered URLs need that limit. ShellHookno 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. DistributedConfigrequires 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 fromGET /triggers. - Unknown keys in the section config models (
FutureWarning; they will be rejected). - The manifest
entrypointkey, which the runtime never read (FutureWarning). Declare hooks inhooks:instead. MissionConfig.on_completeremains deprecated and ignored (FutureWarning); useauto_complete.