changelog

What changed, and why.

Written at commit time. Read Removed and Changed first: those are the sections with something for you to do.

Written at commit time rather than reconstructed later. The public API is 0.x and breaking changes are expected until the design survives a second consumer.

0.1.4

Added

  • Workflows with structure. WorkflowSpec.steps may now hold nine more step kinds beside tool, agent and workflow: parallel, branch (data conditions, first or all, an otherwise arm), foreach (with concurrency), loop (until or while, bounded by max_iterations), map, set_state, sleep, wait (an event delivered by resume()) and human (a question with no model in the loop). Every step takes when, retry, timeout_seconds, on_failure and output_schema; the workflow takes input_schema, initial_state, output and a default retry. Steps read each other by reference (ValuePath: input., steps.<name>.output., state., item, index, iteration) and never by template. Composite steps are steps in the log, so the tree is reconstructable without the Spec and a finished parallel branch survives a crash in its sibling.

  • Steps that wait hold no Worker. A sleep, a retry backoff, a wait, a human step and a tool step the Policy asks a person about all suspend the Run with the step named on the record. Two new SuspendReason values: timer (parked with a wake time, claimed again when it passes) and breakpoint. RunStatus gains pending_wait and suspended_step_id; PendingApproval and PendingQuestion carry step_id and allow call_id=None.

  • psych_runtime.workflow_view(): the Run as a tree of StepViews with status, attempts, input, output, failure, timing and children, plus the step it is waiting on. psych_runtime.replay() starts a new Run from a chosen step, copying the source's earlier results into its own log. dispatch(breakpoints=, step_mode=) pauses a workflow before named steps, or every step, and a resume payload toggles stepping.

  • Retries record each attempt as its own row (will_retry, retry_at), back off by parking the Run, and never retry a denial or a composite whose child already settled. Limits.max_steps now bounds a workflow's step starts, iterations included.

  • The builder gained one method per step kind and ref(), lit(), when(), all_of(), any_of(). StepReport and SuspensionReport carry the new step fields. WorkflowRequired names a workflow-only operation asked of an agent Run.

  • The playground authors and runs the whole of it. The workflow builder edits every step kind (a Build tab, a Shape tab for input schema, state, output and retries, and a JSON tab), the workflow page draws the definition as a graph and lists the workflow's runs with a state filter, and a run's new Workflow view shows the live step tree with a detail panel per step, the waiting step's controls (approve or deny with the exact arguments, answer, deliver an event checked against its schema, wake a timer, continue or step through a breakpoint), and a replay dialog. New routes: GET /api/runs/{id}/workflow, POST /api/runs/{id}/replay, GET /api/workflows/{id}/runs, POST /api/runs/{id}/events; dispatch takes structured input, breakpoints and step_mode. A workflow tool step is gated by the account's approval selectors, as a model's call is.

  • The console's authoring and settings pages were rewritten for people who are not engineers. Every explanatory paragraph now lives behind a "?" beside the thing it explains (hover for one line, click to pin), and settings read as tables of labelled rows with toggles rather than stacks of cards. A new agent publishes in two clicks: essentials on top, then what it can use, how it behaves and, behind "Show advanced", limits and model options. Everything that works out of the box is on by default (questions, plans, components, sub-agents, summarising, approvals for risky actions, code execution when a sandbox is available, every registered tool and connection); code execution asks for the isolation the default profile actually provides. Settings gained a sticky section navigation and tables for providers, secrets, skills and prices; Connections and Capabilities became tables with actions in the row.

  • The playground is useful before anything is configured. Creating an account fills it from a catalogue (app/catalogue.py): thirteen model providers (OpenAI, Anthropic, Azure OpenAI, AWS Bedrock, Cloudflare Workers AI, Google Gemini, Groq, Mistral, OpenRouter, Together AI, xAI, DeepSeek and a local Ollama) with their address, a "Get a key" link and the models each one serves today with its list price per million tokens beside every id, in the provider dialog and in every model picker on the agent form (no key is ever seeded; the first key saved takes over as the provider in use). Those list prices also cost a Run on a model the library's bundled snapshot does not know, under the account's own Prices page. GET /api/models now carries a price per model and falls back to the catalogue's list when the provider will not list its own, twenty-five vendor-run remote MCP connectors (GitHub, Notion, Atlassian, Linear, Slack, Sentry, Stripe, Vercel, Cloudflare, Supabase, PostHog, ...) each connected by one click and a sign-in or a token, one specialist agent per connector, a Psych agent that holds every specialist as a sub-agent and can spawn more, and five use-case workflows (a GitHub activity report, a Jira sprint digest, inbox-and-issues triage, release notes, an incident summary). Psych carries the host tools list_agents, create_agent, update_agent, list_workflows, create_workflow and run_workflow, so a conversation can build what the catalogue lacks; each write is approved in the console before it takes effect. GET /api/catalogue reports every entry with this account's status and POST /api/catalogue/seed adds what is missing, idempotently; a "Get started" card ticks off the three steps between a new account and a working conversation. The console's demo tools (lookup_order, issue_refund, check_inventory) and the three agent form templates are gone; a seeded provider without a key reads "Needs a key" and asks for exactly that, and a provider whose address carries a placeholder (Cloudflare's account id) asks for it in the same dialog.

Changed

  • A workflow Run that fails now settles with the failing step's failure on run_settled, so status().failure_kind says what failed instead of None.
  • An AgentStep with a mapped input delivers input.message to the nested agent as the user message at that step, in both the model's conversation and thread().

Fixed

  • McpServerUnreachable no longer ends in a bare colon when the transport error carried no text: an asyncio.TimeoutError now reads "the connection timed out", and a reason-less failure names the exception type. The console showed "is unreachable: " for a connector that took too long.
  • The Capabilities page could show placeholders forever on a machine without the databases: the four-stores scenario's availability probe sat in a connect that never returned. Every probe is now bounded and they run together, and a scenario whose check does not answer is listed as unavailable with that reason.
  • A Windows sandbox run whose task was cancelled mid-program terminated the job but never reaped the child, which surfaced at garbage collection as a still-running subprocess with an unclosed transport. Cancellation now waits for the child and drains its pipes before propagating.
  • On a phone, page titles sat under the floating sidebar button.

Security

  • A deferred MCP server's tools bypassed approval. Once a server's catalogue is deferred out of the prompt (automatically, above DEFAULT_CATALOGUE_BUDGET_CHARS), the model reaches its tools through the call_tool wrapper, and the approval gate classified the call by the wrapper's own hard-coded write annotation. A @destructive selector that suspended a preloaded tool let the very same tool run, with no approval record, once the catalogue grew. The gate now resolves the target the call names and decides on its own name and annotations, exactly as a preloaded catalogue would have been judged; a target the Run cannot resolve is graded destructive so the wrapper fails closed. The Policy port is asked about the target's qualified name too, so name-keyed rules match through the wrapper.

  • resume, send and interrupt take a Scope. Every read entry point already refused a Run belonging to another tenant; the three writes took a bare run_id, so anyone holding one could approve another tenant's destructive call, steer their conversation, or stop their Run. All three now accept scope= and refuse a mismatch with AccessDenied, the way psych_runtime.records does. The parameter is optional so existing callers keep working; a consumer serving end users should pass it.

  • The container sandbox's IPC socket now demands a per-execution token. The socket has to be connectable by a uid that is not the worker's, and /tmp itself can be listed, so another local account that found it could connect first and answer as the program. The bootstrap presents a random token as its first line; the listener drops any connection that does not, and refuses a second one.

  • Response bodies from remote parties are bounded. HttpTransport.request reads incrementally and refuses a body past 16 MiB (max_bytes= per call, ResponseTooLarge when exceeded), which covers Agent Cards, A2A replies and OAuth discovery documents. The model adapter reads at most 64 KiB of an error page and stops a server-sent-event stream past 256 MiB.

  • OAuth refuses plain http:// to anything but loopback. The resource URI, the metadata URL a 401 challenge names, and every endpoint an authorization server document advertises must be https://, as OAuth 2.1 requires; a misconfigured server no longer receives tokens and client credentials in clear. Loopback stays allowed for development servers and test stubs.

  • An A2A task_id is forwarded only when this Run received it. Connections are pooled per tenant and principal, not per Run, so a prompt-injected Run could continue any task the same tenant ever opened with a peer. The runtime hands the client the task ids its own log shows, and anything else is refused before the peer is asked.

  • The Windows sandbox never grades filesystem as enforced. The backend has no filesystem containment, and an unreadable canary (an antivirus lock, an ACL quirk) is not evidence of any. The subprocess backend likewise leaves process_count unverified when the privilege drop itself is unverified.

  • The playground refuses internal addresses by default. A fresh account's empty allow-list allowed everything, including 169.254.169.254 and the other services on the compose network. Public hosts and loopback (a local model) stay open; link-local, private and cloud-metadata addresses need an explicit, wildcard-free entry. The fastapi scaffold, whose caller() trusts X-Tenant/X-User headers, refuses to start unless PSYCH_DEV_HEADER_AUTH=1 says this is local development. docker-compose.yml no longer ships a Postgres password; set PSYCH_POSTGRES_PASSWORD (see .env.example). The playground compares session tokens in constant time.

Fixed

  • A repeated tool-call id no longer abandons the Run. A provider or proxy that emits two tool calls with one id produced a log the reducer rightly refuses as corrupt, and the Worker gave up on the Run with no terminal record. The second call is now recorded under a fresh id and failed with duplicate_call_id, which the model reads like any other failure, and the Run settles normally.

  • Two settlement paths never told the parent. An attempt that raised past every handler, and a suspension that expired at resume(), both wrote run_settled without notifying the parent, so a parent suspended on its children waited out children_expires_seconds for news that already existed. Every terminal write now goes through notify_parent.

  • The reducer checks the turn and scope on every record. ModelCallStarted, ModelCallFinished, ModelCallFailed and ToolCallStarted carry a turn that was never compared with the open turn, so a model call could start in turn 1 and finish in turn 2 and the report either raised a raw KeyError or attributed the call to the wrong step. A record under another tenant's Scope was accepted the same way. Both are CorruptLog now.

  • A workflow step keeps its nested agent's failure kind. StepFailed exists to carry a nested agent's real failure, and the only catcher was a generic except Exception that recorded kind="StepFailed" and the outer traceback. The runner catches it by type and records the failure it carries.

  • The thread view shows a finished background child. build_conversation gave the model a user message when a child finished; message_views dropped the record, so a chat screen showed an assistant reply reacting to a message the person could not see.

  • Static tool names are validated as strictly as the provider accepts. A CodeTool or HttpTool name is now capped at 64 characters of [A-Za-z0-9_-], the same constraint MCP-minted names are sanitised to, so a name the model would reject fails at publish rather than at the first turn. A static tool wearing the prefix an MCP server or A2A peer of the same Spec would mint (github__search_issues beside a server named github) is refused at publish too, instead of AccessDenied at every turn.

  • An HttpTool URL may not template its host. {name} placeholders are substituted anywhere in the URL, so https://{region}.api.example.com/... let the model choose where the resolved credential was sent. Placeholders in the scheme or authority are refused at publish.

  • MySQL isolation level applies to every pooled connection. SET SESSION ... READ COMMITTED ran on the one connection acquired at pool creation; every connection the pool opened lazily afterwards kept REPEATABLE READ and its gap locks. It is now the pool's init_command.

  • DynamoDB create_run cannot strand an idempotency key. The key was claimed and the header written as two conditional puts; a crash between them left a key whose run_id had no header, and every later call with that key raised RunNotFound forever. The pair is now one TransactWriteItems.

  • Filesystem blob writes are atomic. Content and metadata each land via a temporary file and a rename, content first, so a concurrent reader on a shared filesystem never sees a partial result or content without metadata.

  • Smaller things. subscribe() logs a task that dies instead of losing the exception at garbage collection. The bounded SSE event source is installed on every MCP connection, not only at import, so a lazily imported SDK module cannot keep the 1 MB default. HTTP 409 is no longer retried: gateways use it for idempotency conflicts, where a retry double-submits. The backoff exponent is clamped. Dynamic Client Registration's token_endpoint_auth_method is configurable rather than always none, and a public client discards any secret the server returns. The MySQL schema says why it has no state CHECK constraint.

Fixed

  • A program that called a tool broke the model call after it. A run_code program can call tools of its own, and each of those calls is journalled with a parent_call_id naming the program's own call. The conversation the model sees is projected from that log, and it replayed every finished call as a tool result — including the program's, whose ids no assistant message ever issued. The next request therefore carried an answer to a question the model never asked, which providers reject outright: a single call_tool in a program was enough to fail the following turn with a complaint about an unrecognised call id.

    The projection now leaves a program's own calls out. Nothing is lost: what the program did with each result already reaches the model inside the enclosing run_code result, and the nested pair stays in the log and in the report, where parent_call_id is what makes the call tree readable.

  • A mistyped tool name came back as an argument error. When a program calls a name it was not offered, the sandbox raises a diagnostic listing what is available. With a large bound catalogue that list outgrew the cap on a failure message, so building the report about the first failure raised a second error — and the consumer described that one, reporting a schema mismatch for arguments that were never wrong.

    why

    Two changes, either of which would have prevented it. The list of available names is now bounded where it is built and says how many names it left out, so catalogue size cannot decide whether the diagnostic exists. And the caps on ToolFailure and SandboxFailure now truncate rather than refuse: they bound what the model is handed, which is a reason to cut over-long text, not a reason to fail while describing a failure that already happened.

0.1.3

Fixed

  • A server offering a large tool catalogue was reported as unreachable. A Streamable HTTP server answers each JSON-RPC request by writing the whole result as a single server-sent event, and the HTTP client under the MCP SDK refuses any one event over a megabyte. A tools/list from a server with a few hundred tools and full JSON Schemas passes that easily, so the limit was in practice a cap on how many tools a server could offer.

    why

    It was also invisible. The SDK reads its event stream inside a bare except Exception, which discarded the real cause and synthesised SSE stream ended without a response; Psych relayed that as McpServerUnreachable. Every layer underneath — DNS, TLS, OAuth discovery, token minting, the handshake itself — reported success, so the failure pointed at the network, the credentials, or the server, none of which were involved.

    The ceiling is now Psych's rather than the client's default, set by psych_runtime.set_max_sse_event_bytes and large enough for a real catalogue. It is still a ceiling: an MCP server is a third party, and a reply nothing bounds is one that can exhaust the process reading it. Reaching it now raises McpResponseTooLarge, which names the ceiling and says the server answered correctly. That type is deliberately not McpServerUnreachable, so a server marked optional is no longer silently dropped for sending a reply that is merely large.

0.1.2

Added

  • A sandboxed program can call every tool its agent is currently authorized to call, MCP tools included. CodeExecution.bindings=None — the default — now means every registered CodeTool, HttpTool, granted MCP tool and A2A peer skill that survives narrowing this turn, rather than only the names in AgentSpec.tools. An explicit tuple still narrows and can never widen.

    why

    This is a behaviour change for a Spec already published with bindings=None: it reaches more tools than before, without its Version hash changing, because which tools exist is a runtime fact and was never in the hash. A Spec that wants the old set names it.

    The set is resolved at every turn boundary from that turn's live catalogue (ResolvedTools.bindable), so a tool a server removed or a tenant policy withdrew stops being callable from a program in the same turn it stops being callable directly. A server whose catalogue is deferred out of the prompt is still bindable, which is the case code execution is best at.

  • Inside a program: call_tool(name, arguments) reaches any tool by its model-facing name — MCP names are not Python identifiers — plus a direct alias for each name that is one, TOOLS, and ToolError carrying a stable .kind so a program can tell a refusal from an outage without matching on prose.

  • BindingBudget on SandboxProfile bounds what one program may spend on host calls: calls per execution and per Run, argument and result bytes per call, total transferred bytes per execution, total bytes produced by host tools per execution, and total bytes preserved in the log or BlobStore per Run. Produced and preserved are metered separately from transfer because a result replaced by a handle crosses into the sandbox as a hundred bytes while costing the host everything it took to fetch and store it; the preserved ceiling is read from the Run's log, so a Worker replacement does not refill it. Owned by the deployment, narrowable by a CodeExecutionPolicy, and not in the Spec or the Version hash.

  • A binding result too large to send into the sandbox in one piece now arrives as a handle rather than failing. The program reads it in bounded windows (await handle.read(offset=…, limit=…)) or searches it (pattern=…) through read_tool_output, which runs on the host against the Run's own log or BlobStore, spends the same call and byte budgets as any other binding call, and is recorded under the same parent run_code. Nothing about the storage crosses the boundary — no key, path, URL, client or credential — and a program may read only handles its own calls produced, so one from a direct call, an earlier program or another Run is refused (binding_handle_not_yours), and every rejected read is recorded as its own tool call under the program that attempted it. A result over the ceiling that was not preserved is refused explicitly (binding_result_not_paged) rather than truncated, and in the child a handle raises ToolError on any attempt to index, iterate, measure or .get() it, because a program computing over a silently shortened result is the one wrong answer nobody downstream can detect. read_tool_output is bindable for this reason; it was previously excluded as redundant.

  • ToolCallStarted.parent_call_id and ToolCallReport.parent_call_id: a call a program made points at the run_code that made it, so a report and a trace can nest forty MCP calls under the one tool call the model actually issued.

  • code_execution.bindings may name a server-qualified MCP or A2A tool at publish time. It is checked as far as offline validation honestly can — that the name is addressed to a system this agent connects to — because whether that server still offers it is not a fact about the Spec and publication must not depend on somebody else's uptime. A name that does not resolve at run time comes back as unavailable_bindings in the run_code result and is never replaced with a different tool.

Fixed

  • An approval-gated HTTP, MCP or A2A tool was callable from inside a program with no approval. The approval selectors match on a tool's annotations, and the agent loop looked those up in the code-tool registry, which holds no registration for any of those three: no definition was found, approval_required was never consulted, and the call went through. The annotations now come from the turn's resolved tool set. A gated call from a program is refused before the tool runs, with kind approval_required_in_program, and the model is told to call it directly.

  • A binding whose result was large enough to offload reached the program as None. The value was read back off the record, and an offloaded record carries its payload in the BlobStore with result empty by that record's own rule. The elision exists for the model's context; a program is the one consumer that must see the whole result, since reducing it is why the program was written.

  • The per-Run binding allowance (BindingBudget.max_calls_per_run) was counted in memory, so a Worker that died holding a lease handed the reclaiming Attempt a fresh allowance and the per-Run cap bounded nothing across a crash. It is now derived from the Run's own folded log: a call is spent when its tool_call_started record is written, whether it then succeeded, failed, was refused, was aborted, or was left dangling, and recovery gives none of it back. Calls the model made directly, and the run_code calls themselves, are not counted.

  • POSIX process sandboxes applied the execution's process_count directly as RLIMIT_NPROC, although the kernel interprets it as an absolute per-user total. A CI runner or desktop user already above that number could not spawn one legitimate child. Psych now adds the target account's observed baseline before applying the cap; whole-tree teardown remains unchanged.

  • Cancelling a remote sandbox execution during a host binding could close the reference service's socket without awaiting transport shutdown. Every connection now completes wait_closed(), including service teardown and caller-task cancellation paths.

  • An agent decides whether it may run programs, in its own Spec: AgentSpec.code_execution names a sandbox profile, the isolation level it needs, its network policy, its limits, which of its tools a program may call, and how much of a program's output reaches the model. It is part of the Version hash, and an agent without it is never shown run_code.

  • Deployments register sandboxes under logical names (Runtime(sandboxes=...), SandboxProfile, SandboxProfiles) with their own hard limits, and can narrow further per tenant with a CodeExecutionPolicy. The terms of an execution are the intersection of backend, policy, Spec and profile, and each stage may only narrow.

  • Every backend reports what it achieved rather than what it attempted: SandboxDescription, SandboxGuarantees and Enforcement grade ten guarantees per execution.

  • A program is not run at all when the isolation it asked for is not available. The child reports its containment, the host accepts it or sends an abort, and only then is the program sent; a remote service must refuse before executing for the same reason, and the adapter will not send a program to one that already says it cannot comply. Output produced under weaker terms is still withheld, as the backstop for a backend that misreported itself.

  • New backends: bubblewrap namespaces on Linux, Job Objects on Windows, a seatbelt profile on macOS, and RemoteSandbox for a sandbox service reached over HTTP. local_sandbox() picks the strongest local backend and refuses rather than downgrading; detect_local_backends() reports what a host can offer. Nothing is installed, downloaded or pulled implicitly.

  • A tool result can carry named attachments (ResultAttachment): stdout, stderr, the returned value and files a program wrote, each with its own handle, size and content type, kept inline or in the BlobStore and read back through read_tool_output by line window or pattern.

  • psych_runtime.testing.sandbox_service ships ScriptedSandbox and a real local SandboxService for testing an agent's use of run_code, and a remote adapter, without a child process or an external provider.

  • The Playground configures code execution per agent, manages sandbox profiles with a health check that runs no model-written code, and shows a program, its containment, its streams, its artifacts and its spilled output in chat, Activity and Trace.

Changed

  • Runtime(sandbox=...) still works and registers that sandbox as the profile named default, but an agent now has to opt in with code_execution before it is offered run_code. Nothing else about the Sandbox port changed for an existing implementation: describe() is optional, and a backend without one is reported as process-level and unverified rather than refused.
  • The Playground image installs current Debian security updates in its runtime stage before adding runtime packages, so fixed base-system vulnerabilities do not remain in a release built from an older pinned base layer.

0.1.1

Fixed

  • A Run executed inline now settles. Store.settle_inline(run_id) is new on the port and implemented by all four adapters: it moves a NESTED Run to SETTLED and touches nothing else. release could never do it, because it matches on the lease holder and a NESTED Run — a subagent executed inline, or anything else driven by whoever dispatched it — has never had one, so the settle an inline delegate already issued matched no row. The header stayed nested for good beside a log saying the Run had finished, and overdue_deadlines, which selects everything not settled, reported it as a supervision candidate for ever. The state is the authorisation: only NESTED may be settled this way, because only a NESTED Run has no competing writer.

  • A nested Run can be admitted against PostgreSQL. The psych_runs check constraint still listed the four states that existed before nested was added, so create_run raised a check violation and every inline delegation failed there while passing against the in-memory adapter, which enforces no such list. Migration 0004_nested_run_state.sql widens it rather than dropping it: a state outside the enum is still a bug worth catching.

  • The remote sandbox adapter bounds the number of frames and of empty lines in a response as well as its bytes, parses without repeatedly slicing the front of its buffer, and hands the event loop back every few hundred lines — a stream can sit well inside the byte ceiling and still be millions of lines, and parsing them all in one synchronous pass disables the very timeout meant to end it.

  • MCP connection edits now send only writable fields, and validation failures are shown as readable field errors.

  • OAuth client-credentials connections renew expired access tokens without requiring a manual reconnect.

  • Opening any message from Activity now keeps the complete conversation available, including later messages.

  • A2A webhook responses are closed after delivery so connection resources are released promptly.

Changed

  • The Playground has a more compact visual system across agents, workflows, connections, settings, chat and Activity. Forms use one page scroll, common agent abilities share one control group, and connection authentication shows only the fields required by the selected method.
  • Activity traces now present each message as a readable sequence of model, tool and waiting events instead of a dense proportional lane chart.
  • Active chat controls use clearer timing choices for guiding the current run, adding a follow-up, or saving a message for the next run.
  • New agents can begin from general assistant, research analyst, or operations coordinator starters. The general starter includes every A2A peer currently saved in the workspace.

0.1.0

The first working runtime. psych_runtime.dispatch() admits a Run, a Worker claims and executes it, and psych_runtime.report() says what it did.

Removed

  • SuspendReason.SCHEDULED. Nothing in the library ever produced it. A consumer switching exhaustively over the enum wrote a branch that could never execute, their coverage tool flagged it forever, and from outside there was no way to tell a misreading of the runtime from a bug in it. The repository gate forbids a shipped path with nothing behind it, and an extension point documented nowhere and tested nowhere is not one.

    why

    DESIGN.md §1 refuses a scheduler, so nothing planned would have produced it either. A Run waiting on a clock suspends as EXTERNAL: from the runtime's side a clock and a webhook are the same thing, something outside Psych that calls back. If that ever changes the member returns with a producer and a test on the same day. Deleting is reversible; shipping a member that lies about what the runtime can do is not.

Changed

  • MemoryStore the adapter is now InMemoryStore, and MemoryBlobStore is InMemoryBlobStore. psych_runtime.store.memory.MemoryStore and psych_runtime.memory.port.MemoryStore were two types sharing one name, which is why neither could be exported: the port most consumers implement was unnameable from a surface claiming to be complete.

    why

    The port keeps the plain name because it is the one consumers type. InMemoryStore matches InMemorySecretResolver and InMemoryAuthorizationRedirect, already in the codebase. MemoryBlobStore had the same defect and no collision to reveal it, so it was renamed too rather than left as a second spelling of one idea with no rule behind it.

    All three are now exported: psych_runtime.MemoryStore (the port), psych_runtime.InMemoryStore and psych_runtime.InMemoryBlobStore.

  • The import is now psych_runtime, matching the distribution. psych is already taken on PyPI by an unrelated project, so sharing the import name would have meant two packages fighting over one module on any machine with both installed. import psych becomes import psych_runtime, and every attribute follows. The command line stays psych, where there is no such collision.

    Mechanical for a consumer: one import line per file, and nothing about behaviour, shape or naming inside the library changed. Done now rather than later because it is a rename before anybody depends on it and a migration after.

  • The version has one source of truth. psych_runtime.__version__ is it, and pyproject.toml reads it through [tool.hatch.version]. Two places to bump is one place to forget, and a wheel whose metadata disagrees with the module a user just imported is a bug nobody notices until a bug report cites the wrong version.

  • The distribution is psych-runtime. pip install psych-runtime, and the import is psych_runtime. psych was already taken on PyPI by an unrelated project, so sharing the import name would have meant two packages fighting over one module on the same machine. psych stays as the command line's name, where there is no such collision. Project URLs point at psychruntime.com and the psych-systems organisation.

Added

  • psych_runtime.stream_text(), for a chat UI that renders only the assistant's words. stream() stays the complete truth; this is a projection over it, exactly as answer() is. It ships for the reason answer(), status() and thread() ship: every consumer writes this loop and every one gets the same three things wrong. Text arrives on model_call_finished rather than a record named for text, an abort is a Record rather than an exception, and the iterator ends when the Run settles rather than when the model stops talking.

    why

    It refuses to swallow what it cannot render. A failed Run raises RunFailed; an interrupted or timed-out one raises RunAborted carrying the terminal state, so a user's stop is distinguishable from a deadline. Both subclass RunEndedWithoutAnswer. Returning quietly would be the bug this exists to prevent: a blank reply, no error, and a log that says exactly what went wrong.

  • Benchmarks that fail rather than report (benchmarks/). Four numbers, each with a committed baseline and a tolerance: dispatch throughput, claim latency under contention, log bytes per turn, and claims per second across 32 Workers. That last one is the build-versus-buy number, the one nobody publishes and the first thing a platform team asks, because it is the difference between adding a Worker and sharding the store.

    why

    A benchmark that prints numbers gets ignored within two months, so these run in CI and fail past their tolerance. Tolerances are wide to start with: a shared runner is noisy, and a benchmark that fails on a busy neighbour teaches people to rerun until it passes, which is worse than having none. The baselines carry the machine that produced them, and a failure prints both shapes so "different box" is the first thing a reader can rule out. PSYCH_BENCH_POSTGRES_DSN measures against real PostgreSQL, which is the only configuration in which the scaling ceiling means anything.

  • A deprecation policy, in CONTRIBUTING.md. A name scheduled for removal ships at least one minor release emitting a DeprecationWarning that names its replacement; deprecated in 0.N means removed no earlier than 0.N+2; the changelog gets a Deprecated section. Two exemptions: security fixes, and anything never documented as public. That second one is what keeps the promise affordable, and the line is mechanical rather than a judgement call because tests/unit/test_public_surface.py already defines what public means.

  • The async-only surface is now a documented decision rather than an omission, in docs/api.md, with worked Django and Celery examples. There is no psych_runtime.sync and there will not be one: asyncio.run() builds a fresh loop per call and an asyncpg pool binds to its creating loop, so a facade would either open a pool per call or hand back one bound to a dead loop, and either failure would read as a Psych bug. A facade over Worker is refused outright, since a Worker renews a lease on a timer and a sync wrapper is a lease that stops being renewed the moment the caller blocks.

  • web/: psychruntime.com, and the machinery that stops it going stale. One Next.js app in web/site/ exports to plain HTML and serves both halves of the site: the marketing pages, and the documentation under /docs. It is static assets on Cloudflare, with no server, no bindings and no runtime.

    why

    They are one deployment rather than two because they share a design system, a header, the Trident and every fact about the package, and because a reader moving from a capability to the guide explaining it should not change origin to do it. docs.psychruntime.com becomes a redirect to psychruntime.com/docs.

    The part that matters is not the pages. It is that almost nothing in them is written twice. reference/* is generated from psych_runtime.__all__ by scripts/generate_docs.py: signatures, kinds, docstrings and model fields, read out of the live module. The changelog, the public-API document and every design note are copied from the repository by web/scripts/sync-from-repo.mjs, and the 26 skills are published as the feature guides rather than rewritten. The changelog route renders that copied page inside the marketing shell, so there is one changelog and it is CHANGELOG.md.

    scripts/check.sh and a CI job run all of it with --check and fail on a diff. Change a docstring, a design note or the changelog without regenerating and the build goes red, naming the command that fixes it. So the documentation cannot fall behind the library: not by discipline, and not by anybody remembering.

    web/scripts/check-site.mjs does the same for the marketing pages, which are the documents nobody re-reads after launch and which carry the strings a visitor copies first. Every fact they state about the package lives in web/site/content/site/ and lib/site.ts, apart from the components that lay it out, and the check reads those files: the install command against the real distribution name, and every psych_runtime symbol in a sample against __all__.

    The Trident is the brand mark, drawn once in components/Trident.tsx and used as the wordmark, the favicon, the touch icon, the social image and, labelled, as the architecture diagram: three prongs for the three ports a consumer supplies, one stem for the record log they fold into. Interface icons come from lucide-react; only the mark is drawn by hand.

    pnpm cut 0.2.0 freezes content/docs/next as a released tree, rewriting its own links to the new version as it copies. It refuses when __version__ does not already say 0.2.0, so the docs cannot announce a release the package does not carry, and refuses to re-cut an existing tree, because a released version is corrected in place rather than replaced.

  • A command line, so the first run needs no program at all. pip install psych-runtime && psych new demo && cd demo && python main.py prints an answer on a machine with no API key, no database and no Docker: the generated project falls back to the scriptable fake model when no provider is configured, so the whole agent loop executes offline. --template tour generates a longer one that walks through an approval suspending, a skill loading on demand, a fact surviving its Run, a memoised workflow step and the report.

    why

    A third template, --template fastapi, generates the integration every consumer writes first and which was previously only visible inside an 800-line playground backend: routes that admit and read Runs, a separate worker.py that executes them, and an agent.py both import. It exists to show the split rather than the routes -- admitting a Run is a fast write that belongs in a request handler, executing one is minutes of model and tool calls that belongs in its own process, and they share a Store and nothing else. With no PSYCH_POSTGRES_DSN set, main.py hosts a Worker itself and worker.py refuses to start rather than claiming from an in-memory store no other process can see.

    psych skills install copies the 26 guides into a consumer's own .agents/skills/, which is why .agents/skills is now force-included in the wheel at psych_runtime/_skills; the canonical copy stays where this repository's own agents already find it. psych doctor reports the version, which optional adapters import, which environment variables are set and where the skills are, and opens no socket and no database connection while doing it.

    It never executes a Run, and that boundary is load-bearing rather than an omission. psych new writes a main.py the developer owns and runs themselves. A command line that could start a Worker is an operational surface Psych would have to keep working, version and answer questions about, which is the definition of the platform DESIGN.md §1 refuses to become. Both templates are generated and executed by tests/functional/test_cli.py, asserting on their output rather than their exit code, because the tour's approval silently failed to fire once and the run still exited 0.

  • psych_runtime.session(): a first program that fits on a screen. The smallest working program was around forty lines of assembly before anything ran, and a library whose on-ramp is setup gets evaluated on the setup. session() assembles a store, a registry, a Runtime and a running Worker, gives you await s.ask(spec, "..."), and stops them again on the way out, including when the block raises.

    why

    It is wiring and nothing else. store, registry, runtime and worker are the real objects, public, and reaching past them is the expected path out rather than a failure of it, so there is one execution model underneath either way rather than the two DESIGN.md §4 warns about. The model stays required: defaulting it would pick a provider, an endpoint and a credential on a consumer's behalf, and the first run would either bill them or fail on a network error unrelated to the code they just wrote.

  • Everything reachable from the public surface is now nameable from it. psych_runtime's own docstring says the public API is that module and nothing else, and that was false in a way nobody notices until they write typed code: RunReport was exported and ToolCallReport was not, so annotating a function that takes one meant importing a submodule path the same docstring calls internal. WorkflowSpec was exported without the three step types it cannot be constructed without.

    why

    Added: the workflow step types, the two fluent builders (psych_runtime.agent() and psych_runtime.workflow(), documented as an authoring form and previously reachable only through psych_runtime.builder.*), every report and status type, the id aliases, the enums a consumer compares against (TerminalState, SuspendReason, ToolOutcome), and the ports a consumer implements (Store, BlobStore, ModelClient, Policy, SecretResolver, Telemetry, Sandbox, PriceResolver, EgressPolicy). Additive only; nothing moved and nothing changed shape. tests/unit/test_public_surface.py pins the rule mechanically, so it cannot drift back.

    One name stays off the surface. psych_runtime.store.memory.InMemoryStore and psych_runtime.memory.port.MemoryStore are different types sharing a name, so neither is exported under it. Resolving that collision is a rename and a deliberate breaking change, not something to smuggle in behind an alias.

  • 26 agent-facing skills under .agents/skills/. One per feature, plus a psych router carrying the mental model, the vocabulary, the invariants that get a change rejected, and an index. They exist because a coding agent that has never seen this codebase cannot guess the rules that matter most here: a Spec holds names and never callables, an MCP pool keys by scope and not by URL, an unknown price is None and never 0. Plain Markdown with YAML frontmatter, fetchable by raw URL from another repository.

  • The README's and the router skill's examples are executed by the test suite (tests/e2e/test_readme_example.py), extracted from the files themselves and run as subprocesses the way a reader runs them. docs/api.md already had this; the README is the more-copied file and had nothing, and the router skill has the widest blast radius of the three because an agent that copies a broken snippet writes broken code into somebody else's repository and reports it as done.

    tests/unit/test_skill_docs.py covers the rest of what can rot silently in a skill: frontmatter that names itself, a description long enough to route on, Python blocks that are Python, cross-references naming skills that exist, and a router that indexes all 25.

  • The playground exports traces, and Psych's log lines are finally audible there. Runtime has always taken a Telemetry and defaulted to discarding spans; the playground never passed one, so the console emitted none and the OTel adapter got no exercise outside its own conformance tests. A demo that cannot show a trace is quietly claiming the library cannot produce one, and a consumer wiring pattern nobody has run is a pattern with a bug in it.

    why

    app.observability builds the adapter and configures logging. Off unless asked, because standing up a collector to look at a chat window is a worse first experience than having no traces: PSYCH_PLAYGROUND_OTLP_ENDPOINT set means export over OTLP/HTTP, unset means the no-op. docker compose up sets it and brings up Jaeger, so there is a UI to open and a Run to find in it. Spans are flushed on shutdown, after the Worker stops, because a batched exporter that exits without flushing loses exactly the Run somebody just watched.

    GET /api/config reports whether anything is collecting and where, and the Trace panel says so, because "nothing is collecting" and "something is, but not where you are looking" are different problems and look identical from a screen that mentions neither.

    The exporter is chosen here rather than in psych, whose otel extra takes the API and the SDK and stops. Which exporter, which protocol and what sampling are a deployment's decisions.

  • A summariser can be told what this agent's summary must keep. CompactionPolicy.summary_instructions is added to Psych's own rules rather than replacing them, and placed last where a model weighs it most, with the prompt saying in the same breath that the two combine. An agent author knows what their domain cannot afford to lose; nobody knows in advance what a summariser will decide was pleasantry. A terse "keep every order number" read as the whole brief would throw away the constraints and the outstanding work, so it cannot be.

Fixed

  • report.latency does not exist; it is report.totals.latency. Four freshly written skills said otherwise and one template raised on it, which is the argument for executing documentation rather than reviewing it.

  • The playground image can start. It had never been built in CI, because docker build failed on a COPY LICENSE for a file that had never existed in this repository. Fixing that exposed three more, each hidden behind the last: the backend refuses to boot without an OpenAI-compatible endpoint and the gate passed none; import psych_runtime failed because uv sync installs the project editable, pointing the copied venv at a /src that exists only in the build stage; and import aioboto3 failed because the image syncs --extra playground, which carries asyncpg alone while app/scenarios/four_stores.py imports all four store adapters at module scope.

    why

    The last one is not a dependency at all. Two scenarios imported McpStubServer from tests/functional/test_mcp.py, and .dockerignore keeps tests out of the build context on purpose, so the backend imported a package that could not be there whatever was installed. The stub server moves to psych_runtime.testing.mcp_stub, which is what that package is for: a helper with two consumers, only one of them a test. It imports no pytest.

  • The gate runs green on an unprivileged runner, and the container backend is actually exercised. Three separate faults kept CI red.

    why

    SubprocessSandbox refuses a child that runs as the worker's own uid, because such a child reads the worker's environment out of /proc/<ppid>/environ. That refusal is right and stays. What was wrong is that four test fixtures never opted out of it: as root the adapter drops the child to "nobody" and the question never arises, so the fixtures passed everywhere they were written and failed on every runner that is not root. They now pass allow_same_uid=True, which is inert wherever a privilege drop is available, and TestSameUidIsRefused still proves the refusal fires without it.

    The container tests were worse than skipped. A runner has docker, so they ran; ContainerSandbox never pulls an image implicitly, so every case started a container that could not start, waited the full 20-second connect-back timeout and reported "the container never connected back". Thirteen of those consumed most of the job. The image is now a checked precondition that skips with the reason and the command to fix it, and the gate pulls it so the backend runs for real rather than being quietly absent.

    The playground image had never built in CI: the Dockerfile copies LICENSE and no LICENSE file has ever existed in this repository, though pyproject.toml, the README and NOTICE have all said MIT from the start. The file is added, which fixes the build and closes the gap between what the package claims and what it ships.

  • The MySQL adapter works against MySQL. MySQLStore.migrate() said it was parity with PostgresStore.migrate() and was not: Postgres records applied versions in psych_schema_migrations and skips them, MySQL had no ledger and reapplied every statement of every file on every call. That forced each migration to carry its own idempotence, and the compact way to write that for a column, ADD COLUMN IF NOT EXISTS, is MariaDB's alone. MySQL 8 rejects it as a syntax error, so every store test errored against the database the adapter is named for.

    why

    It was invisible because scripts/dev-services.sh runs mariadbd under the name "mysql", and MariaDB accepts the statement. The adapter now keeps the same ledger Postgres does, 0002_thread_continuation.sql is plain ALTER TABLE ... ADD COLUMN matching the Postgres file statement for statement, and the test suite calls the shipped migrate() instead of its own second copy of it. test_migrate_is_idempotent exists for MySQL now; Postgres has had it all along, and its absence here is the reason this shipped.

    Upgrade note: a database migrated by the previous version has the schema but no ledger, so the next migrate() replays 0002 and fails on a duplicate column. Only MariaDB can be in that state, since MySQL never got past the syntax error. Insert the rows by hand to adopt it: INSERT INTO psych_schema_migrations (version, name) VALUES (1, '0001_initial.sql'), (2, '0002_thread_continuation.sql').

  • The container sandbox could never connect to its own container. ContainerSandbox bind-mounts a directory and listens on a Unix socket inside it for the container's bootstrap to connect back. The directory was chmod 0755; the socket was left at whatever the umask gave it, 0755 owned by the worker. The container runs --user 65534:65534 by default, and connect(2) needs write permission, so the bootstrap got EACCES and exited, and the adapter spent its full 20-second timeout before reporting a missing interpreter, pointing at the image, which was fine.

    why

    This could not have worked on any host at the default run_as, which is consistent with the tests never having passed anywhere: the development container has no container runtime, and CI had no image, so the backend was skipped in both places. The socket is 0666 now, because it is reached by an account the worker does not control, and the directory drops to 0711 so the name cannot be enumerated. The gate pulls the image, so these thirteen cases run for real rather than being skipped on both sides.

  • A summarising call's time is accounted for. Compaction was metered in tokens and cost from the day it shipped and was never timed, so a summarising call fell into unaccounted_seconds beside "time between turns" and read as a gap nobody could explain. DESIGN.md §23's seventh item asks for a breakdown that accounts for the Run's wall clock, and a call the Run really made has to be in it. CompactionApplied carries timings and LatencyReport gains compaction_seconds, counted apart from model_seconds for the reason the call count is counted apart from model_calls: it is time spent fitting the conversation into the window rather than doing the work.

    why

    Third instance of one mistake, and the useful thing is the shape rather than any of the three. Compaction was added to the money accounting and not to the span schema, not to the "N of M calls" denominator, and not to the latency breakdown. A test now scans psych_runtime/ for every span name the runtime opens and fails if the schema does not declare it, which is the direction that catches the next one: the other checks are written by whoever is thinking about telemetry, and the bug was in code that was not.

  • psych_runtime.compaction is declared in the span schema, not merely emitted. The compaction call opened a span the schema had never heard of, which means no consumer's pipeline expected it and the conformance suite did not check it. It spends real tokens, so a trace missing it cannot be reconciled against a provider's bill. A test now asserts the conformance walk emits every span the schema declares, so the next omission fails rather than drifts.

  • Psych no longer writes to a consumer's stderr uninvited. The root psych logger had no NullHandler, so an application that had configured no logging still got Psych's lease-renewal warnings via Python's lastResort handler. Level, format and destination are the consumer's to choose, and now nothing is chosen for them.

  • The playground ships as a Docker image. docker run -p 3000:3000 and there is a console, with no Python, no Node, no database and no model key to arrange first. docker compose up is the same image with real PostgreSQL behind it.

    why

    The ticket originally weighed uvx against npx and both lose. The playground is two runtimes in one product, so a Node launcher needs a Python interpreter it does not ship and a Python launcher has to carry a Node bundle inside a wheel that every library consumer would then download. An image carries both, pinned, and it also makes the artifacts say what they are: the wheel is the library, the image is the demo. Nothing under psych_runtime/ gained an HTTP server or a UI, and nobody should conclude from a docker run that it did.

    The container publishes one port. The console now proxies /api and /a2a to the backend on loopback inside it, which deletes a whole class of problem rather than solving it: NEXT_PUBLIC_PSYCH_API is baked into the client bundle at build time, so an image built once cannot know the port somebody maps it to later, and a second origin brings CORS and SameSite=Lax cookies that are silently not sent. That last one is the nasty one, because sign-in returns 200 and every request after it is anonymous.

    Verified without a container runtime, which this development environment does not have: the console is built standalone and assembled exactly as the Dockerfile assembles it, then driven in a browser on a port the backend's CORS allowlist does not cover, so every request succeeding is itself the proof that the proxy carried it. Cookies survive, the record stream still streams rather than buffering, and no request leaves the origin. CI builds the image for real and asserts it comes up healthy, is not running as root, and stops when asked.

Fixed

  • Branching and forking are two operations, not one. They were one, doing a confused half of each. Re-asking a message made a branch, every branch became its own row in the history list, and so a second wording of one question appeared in the sidebar as a second conversation.

    why

    Editing a message now makes a version you page between with small arrows, inside the conversation you are already in; branching into a new conversation remains a separate operation that carries the history up to that point.

    So POST /api/runs/{run_id}/branch keeps the conversation_id and takes a new branch_id, and carries no agent_id: a conversation is with one agent for its whole life. POST /api/runs/{run_id}/fork takes a new one of each and keeps the agent picker, because putting one question to two agents is the reason to fork. RunEntry and RunSummary gain conversation_id, defaulted so an older index still loads and read as the root of a Run's own chain when it is absent.

    In the console, a hovered message now offers a branch icon and a fork icon, and a branched message carries 1/2 arrows that page between its versions.

  • Deleting a conversation deletes the conversation, and leaves forks of it standing. Every branch of it goes, because branches are versions of a message rather than chats. A fork is the case that needs care, and Git already answers it: git branch -d deletes a ref, never the commits, so a commit another ref still reaches survives and one nothing reaches is collected. A Run of the deleted conversation that no surviving conversation reads is removed; one a surviving fork does read is kept and unlisted; and a collection pass afterwards removes what an earlier deletion was holding for a fork that has since gone.

    Adds RunEntry.listed, defaulted so an older index still loads.

  • ask_question, update_tasks and show_component are reserved. They are registered by Psych and were missing from RESERVED_TOOL_NAMES, so a Spec could claim one and the model would be shown two tools with a single name and able to address neither. tests/unit/test_builtin_names.py now asserts both directions, because the set has to be written out by hand: psych_runtime.core imports nothing from psych_runtime.tools, so it cannot be built from the constants.

  • A resumed Run no longer claims it recovered from a crash. AttemptStarted.reclaimed_expired_lease was attempt_count > 1, and a second attempt has two unrelated causes: the previous Worker died holding the lease, or the Run suspended cleanly and something woke it. Only the first is a reclaim, so every approved, answered or woken Run told a reader it had been picked up after a crash that never happened. The new RunStateView.resumed_since_attempt folds the difference out of the log, and the telemetry attribute reads the same expression as the record so the two cannot disagree. Waiting on subagents turned this from occasional into constant, which is how it was spotted.

Added

  • A long conversation is summarised instead of hitting the context window. Psych could already represent and replay a compacted conversation and could not compact one, so a conversation grew until the provider refused it and what a person saw was the chat stopping on a 400.

    why

    The record type settled the hard question long before this: the replaced records stay in the log. Compaction changes what the model is sent next, never what happened, so the report, the trace and the audit trail are untouched and the e2e case asserts both halves at once.

    Three decisions were open, and each is argued at the place it happens rather than here. Size is measured, not estimated: the trigger reads the input tokens the provider itself counted for the last call, because Psych takes no tokenizer dependency and an estimate that looks like a measurement is worse than no number. The cost of that is reading the size one call late, which is why CompactionPolicy.trigger_tokens has no default: only the consumer knows which model they pointed this at and how much of its window they want to spend. The cut always lands on a turn boundary, keeping the policy's most recent turns verbatim, because cutting anywhere else could put an assistant message above the line and the tool results answering it below, which is the one conversation shape a provider refuses outright. The summary is written before the record is appended, and the record before the next model call, and the cut is only taken when it advances the boundary already in the log. That one rule is the whole of crash safety: a Worker that died before the append summarises again and pays for one call twice, a Worker that died after it does not summarise at all, and neither compacts one range twice.

    Writing the summary is a model call that is not a turn, so it carries no turn_started and no model-call pair, which the reducer would rightly refuse outside a turn. Its usage and cost ride on the CompactionApplied record instead: the tokens join the Run's totals through the same arithmetic every other call uses, psych_runtime.report() gives the compaction a row of its own, and a summariser with no known price records cost=None like anything else. It may be a cheaper model than the agent's, and it is told what it is doing rather than who the agent is.

    The reactive half is the transient classifier's new third answer, is_context_overflow: a provider refusing a prompt for being too long is worth neither retrying nor giving up on, so the loop compacts and tries again. It matches on the provider's own wording because no provider returns a code for this, and it is deliberately narrow -- a false negative leaves the Run failing exactly as it did before, while a false positive would hide an unrelated 400 behind a summary.

    Opt-in per Spec and off by default, joining the Version hash like every other behaviour switch: an agent that summarises its own history shows the model a different conversation on a long Run, and that difference belongs in what the agent is rather than in Runtime wiring.

  • Compaction is something a person can turn on. In the example console only; nothing under psych_runtime/ changed. A capability the library ships and no screen offers is a capability only its author knows about, and every agent the console had published so far was one that grows until the provider refuses it.

    why

    It is a section on the agent form, off unless somebody turns it on, and the terms appear only once they do: summarising is lossy, and an agent whose conversations are short would be paying a model call to lose detail it was never going to run out of room for. The copy says what actually happens rather than what the field is called again, because the sentence a person needs is that nothing is deleted: the replaced messages stay in the record and in the report, and only what the model is shown next changes. The summariser can be a cheaper model, and the box for extra summary rules says in its own helper text that it is added to what Psych already keeps rather than replacing it, since somebody who read it as the whole brief would type one line and throw away the constraints and the outstanding work.

    trigger_tokens has no default in the library and does not get one here. A form still has to put something in the box, so the console picks a starting number, writes down why beside it, and leaves it an ordinary editable field rather than a constant hidden behind the toggle. The number is a starting point and not a recommendation, and anybody running a model with a smaller window has to lower it. Adding a default to CompactionPolicy to save the console this paragraph would have made Psych guess about somebody else's model on every consumer's behalf.

    The policy comes back whole from GET /api/agents, not as an "on" flag. The edit form is filled from that list, so a flag would have meant somebody who fixed a typo in the instructions republished an agent summarising on terms they never chose. The round trip is asserted through the Version hash, which is the only check that catches a dropped field without a reader noticing it.

    Afterwards it is visible where the money is: the trace's totals gain a summaries count, shown only when there is one, beside the model and tool calls rather than inside them. That also fixed a smaller lie next to it. A summary is charged for but is not a turn, so a conversation that compacted once and had no price table read "no rate for 4 of 3 calls".

  • A conversation can be asked again from any of its messages. In the example console only; nothing under psych_runtime/ changed, because nothing had to.

    why

    A conversation is already a chain of Runs rather than one long Run, each carrying one new fact: RunAdmitted.continues_run_id. So a branch is an ordinary psych_runtime.dispatch(continues=...) naming the Run before the message being re-asked. Two Runs then share a predecessor and nothing in the runtime minds. POST /api/runs/{run_id}/fork is that dispatch, and it may name a different agent_id, which is the useful half: a conversation is with one agent for its whole life, so putting the same question to a second agent means branching to do it.

    At Run boundaries only, never mid-Run. Every user message opens a Run, so both "from this question" and "from that answer" already land on one, and a prefix bound into the log would buy nothing while costing plenty: a Run truncated partway is a Run whose tool calls may have no results, which the reducer would be right to call corrupt. There is no merge either, and there will not be. Two divergent conversations over an append-only log have nothing to reconcile.

    What the feature actually needed was a branch identity. Two Runs continuing one predecessor make the chain a tree, and continues_run_id cannot say which of two futures a Run is in, so the console's forward walk followed whichever child it met first and one of the two answers silently disappeared. That walk sits behind the thread report, the conversation list and opening a chat from history. RunEntry and GET /api/runs now carry a branch_id, stamped at dispatch: a new conversation mints one, a continuation inherits its predecessor's, and a fork mints a fresh one because the message it continues from has already been answered onward. Within a branch each Run has at most one child on it, which is what makes the walk single-valued again. Store gains nothing: DESIGN.md §7 keeps the parent-to-children index out of it deliberately, and enumerating branches is the consumer's job.

    The two folds that did this separately for Chat and for Activity are one function in web/src/lib/branches.ts.

    That fold gave every branch its own row in every list, which is corrected above: a branch is a version of a message inside one chat, and only a fork is a chat of its own.

  • An agent can answer with something structured, not only prose. psych_runtime.core.components adds a closed, versioned vocabulary of six kinds, a card, a carousel of cards, a titled detail record, a timeline, a chart and a metric, plus a show_component built-in, gated on the new AgentSpec.components_enabled and off by default. Each call appends a ComponentShown record; unlike the plan, they accumulate in the order they were shown, because an agent that showed the order and then the delivery timeline showed both. They surface on RunStatus and RunReport.

    why

    Psych owns the payload and the consumer owns the drawing. DESIGN.md §1 refuses a UI and this does not bend it: nothing under psych_runtime/ imports a rendering library or carries a colour, a font or a layout. That is also what makes brand control free rather than configurable, because the consumer never gets styling to override when none was ever sent, and it keeps the payload renderable by a consumer with no browser at all.

    image_url and href validate to http or https and refuse everything else, including javascript: and data:, because those two fields are the only ones a renderer follows. Every string is capped. Parsing is lenient in the same places update_tasks is, and a component that cannot be read comes back as a tool result rather than a tool failure: the component was the garnish, and a Run that already found the right answer should not fail on the way to saying it.

    The example console renders all six with its own tokens, charts as inline SVG with no new dependency, against a categorical palette validated for colour-vision separation in both themes.

  • A2A: other people's agents can talk to yours, and yours to theirs. A2A (Agent2Agent) v1.0, the Linux Foundation protocol, in three pieces that sit either side of the line DESIGN.md §1 draws.

    why

    psych_runtime.a2a is the protocol and nothing else: the data model as validated models ported from specification/a2a.proto (§1.4 makes the proto normative over every generated artifact), the JSON-RPC and HTTP+JSON bindings' envelopes and route tables, the error taxonomy carrying §5.4's three codes per type so two bindings cannot disagree, version and extension negotiation, Agent Card construction from an AgentSpec, RFC 8785 canonicalisation and JWS signing, and push-notification payloads. It imports only psych_runtime.core.

    The centre of it is a pure mapping from a Run to a Task. RunId is taskId, which §3.4.2 requires to be server-generated and Psych already mints. The continues_run_id chain is contextId, which §3.4.1 defines as what "logically groups multiple related Task and Message objects" and is exactly what a continuation chain is. SuspendReason.QUESTION is TASK_STATE_INPUT_REQUIRED, and answering it resumes the same Run and keeps the same task id, which is §3.4.3's own description of the round trip. An external suspension is TASK_STATE_AUTH_REQUIRED. psych_runtime.answer() becomes an Artifact rather than a Message, because §3.7 says outputs are artifacts. Streaming order (§3.5.2) is free: the log is gapless and ordered, and stream_events is a loop over it.

    psych_runtime.tools.a2a is the outbound client, shaped like the MCP client because from inside a Run a peer agent is a tool source. Pooled by (scope, peer, credential) and never by URL. DESIGN.md §10.4's rule holds here for the same reason it holds for MCP, since an A2A call carries a bearer token in a header. Every call goes through the egress seam. Peers are declared in the Spec (AgentSpec.a2a_peers), so which agents an agent may call joins the Version hash: this changes the hash of every existing Spec, the same trade-off McpOAuth documented when it was added.

    examples/playground/backend/app/a2a/ is the transport, because Psych refuses to be one: both bindings, SSE, the webhook sender, durable push configs, and signed cards, in five files that make no protocol decisions.

    Two omissions, both deliberate and both stated where they apply. gRPC is not implemented: §5.2 requires an agent to declare its bindings rather than serve all three, gRPC needs a server and generated stubs, and the two HTTP bindings are functionally equivalent per §5.1. Asymmetric card signatures are a CardSigner a consumer supplies: ES256 needs cryptography, which is not a Psych dependency, while the canonicalisation and JWS assembly, the half that is hard to get right, ship here with a complete HS256 signer over the standard library.

  • Subagents a parent writes at run time, runs in the background, steers and watches (DESIGN.md §17 by way of §11). The authored path is unchanged: a SubagentRef is still embedded in its parent's Spec, still reached through a blocking delegate call, still pinned by one Version hash for the whole tree. This adds a second path beside it rather than replacing it.

    why

    A dynamically composed child has instructions the model wrote during the Run, which are not in the parent's Version and cannot be. What pins them is the child's own Run. So an AgentSpec may now carry a SpawnEnvelope: a permission, not a roster. May this agent compose children, out of which of its own tools, on which models, how deep, and how many alive at once. The envelope joins the Version hash like every other permission. At spawn the parent composes a child Spec inside it, that Spec is published as a Version like any other, and the child Run pins its hash, so a reclaiming Worker resumes the same child rather than one composed from a prompt the model would write differently the second time. Crash recovery is untouched.

    Narrowing composes in one direction only. What a child holds is what the model asked for, narrowed by the envelope's ceiling, narrowed by what the parent itself holds, every step through the same psych_runtime.tools.narrowing the validator and the per-turn resolver use, which selects from the plane above rather than unioning with it. There is no expressible request that reaches a tool the parent does not have, and the test that tries is the one worth reading first.

    The child runs in the background: admitted RUNNABLE with its parent named, claimed by whatever Worker gets to it, settled by that Worker. The parent keeps working. When it runs out of work with children still going it suspends on a new SuspendReason.CHILDREN rather than holding a lease and polling. A fan-out of four would otherwise pin four Workers doing nothing while their own children queued behind them. A fifth reason rather than reusing external because this is the one suspension Psych can reason about: it waits on Runs it admitted and can read, so a parent reconciles its children's own logs before suspending and does not suspend at all if they have already finished.

    A finished child reaches its parent as a Record and never as a callback: subagent_finished, appended by whichever Attempt settled the child, joining psych_runtime.runtime.journal.EXTERNAL_RECORD_TYPES beside an interrupt and a steer. The parent may be suspended, on another machine, or not running at all, and the only thing those three have in common is the log. Losing the wake-up costs latency, bounded by the suspension's own expiry; losing the record is not possible, because it is written first.

    Three new tools, offered only inside an envelope: spawn_subagent, check_subagent and message_subagent. The spawn brief is three fields, what the child is for, the task itself and what it should hand back, each with a minimum length, for the same reason a SubagentRef description has one: vague routing is the failure mode, and refusing a one-line brief at the boundary is cheaper than debugging it afterwards. A message to a running child is queued for its next turn boundary, never injected into the tool call it is in the middle of: that call's side effect has already happened, and cancelling it would only lose the record of whether it did. Stopping a child is a different intention with its own record.

    psych_runtime.report() gains subagents and subtree. totals still covers one Run alone and still says so; subtree is that Run plus every descendant, summed with the same Usage.__add__ and Cost.__add__ a single Run's totals use, so an unpriced call stays out of the sum rather than entering it as zero. It carries complete, which is false while a branch is still working: a total that will change is not a total somebody should quote.

    The example console grows a flag on the agent form and a live tree in both the chat and the trace view, each child with its status, its own log one click away, tokens and cost per branch, and the three controls a person wants at the moment they can see a child heading the wrong way: stop it, tell it something, run it again. Every control goes through the same mechanism the agent's own tool uses; the console gets no privileged path.

  • An agent is a name, and a Version is what it points at. In the example console only; nothing under psych_runtime/ changed and nothing about immutability moved.

    why

    A Version has to be immutable. psych_runtime.runtime.execute reloads the pinned Version at the top of every Attempt, a reclaiming Worker's included, so a Run whose Spec could change underneath would resume a conversation the model never had, which is DESIGN.md §23's second item. What was wrong was the console having no notion of an agent apart from a Version of one: the list showed Versions, editing meant duplicating, and flipping one flag produced a second row that looked like a second agent.

    So the playground's index now holds an AgentPointer beside its Version entries: a stable agent_id that survives every edit, a name, the Version it currently runs, and the ordered history of everything it has run. Docker tags and Git branches are the same arrangement. POST /api/agents takes an optional agent_id and moves that agent to what it publishes; GET /api/agents returns one row per agent; GET /api/agents/{id}/versions is the history; DELETE /api/agents/{id} takes an agent id where it took a version hash. POST /api/runs takes either an agent_id, resolved once at admission, or a version_hash to pin one deliberately, which is what continuing a thread does, so editing an agent mid-conversation cannot change what the next message runs.

    A Run records agent_id beside version_hash, and neither is derivable from the other. Two agents built from the same Spec share a Version, so attributing a Run by hash shows one agent's conversations under its twin; editing an agent moves it off the hash its earlier Runs pinned, so attributing by the current hash orphans them. Both are settled at admission. A Run continuing another takes both fields from it.

    AgentEntry also carries may_ask_questions and tasks_enabled now. Both join the Version hash, so an edit form that could not read them back was publishing a different agent from the one somebody meant to edit.

    Breaking for anything calling this example's API: DELETE /api/agents/{hash} is now DELETE /api/agents/{agent_id}, DispatchRequest.version_hash is optional and exactly one of it and agent_id must be set, and created in the publish response means "an agent was created" rather than "this owner had not published this hash before". An index file written by an older build upgrades in place: every entry with no agent_id becomes its own agent, with no merging by name.

  • One scrollbar per page in the console. The shell's content pane is relative, and a descendant positioned against it reached past its box: that counted toward the document's scroll height without making any layout box bigger, so the agent form scrolled inside its own pane and shifted the whole console including the sidebar by 47px. Clipping the pane fixes it and hides nothing, since the scrolling happens inside it and menus and dialogs render in portals at the body. Measured on every page before and after.

  • A2A peers are configurable from the console. psych_runtime.A2APeer shipped with the protocol work, and nothing in the example backend could set one, so A2A was inbound-only in practice: other agents could call yours, yours could call nobody. POST /api/agents now takes a2a, PUT /api/settings/a2a saves peers as presets, and the console offers them under Connections and on the agent form. Each agent's own inbound address is on its detail page, to hand to whoever runs the other agent.

    why

    Presets, in the same sense the MCP and skill lists mean it: attaching one copies its address, credential name and grants into the published Spec, so editing a peer later changes what you publish next and never what a running conversation calls. description is the one field that stays local, because the model learns what a peer does from its Agent Card rather than from here.

  • A skill library in the console. Also example-only. Skills written once, in PlaygroundState beside the MCP presets, offered on the agent form and copied into the Spec at publish.

    why

    The copy is the design, not an implementation shortcut. The obvious alternative is a library the runtime reads at turn time, which is exactly what a server description does, and the two are worth telling apart: a description is a fact about somebody else's system, so injecting it late is right precisely because the agent did not change, while a skill body is instructions the model follows. Let those change under a published Version and two Runs of one Version behave differently, which DESIGN.md §23.1 asks not to happen. So "global" means available to every agent you build, never reaching into every agent you have built, and the page says which.

    Adds PUT /api/settings/skills and SettingsResponse.skills.

  • An agent can stop and ask. SuspendReason.QUESTION and SuspensionPolicy.question_expires_seconds had existed since the suspension machinery was built with nothing able to reach either; only APPROVAL was ever raised. ask_question is the tool that was missing.

    why

    It takes structured questions, not one line of text: up to four questions, each with a short header and up to four labelled options carrying a description of what choosing one means. An open text box makes the person guess what an acceptable answer looks like and makes the model parse prose that may not contain one. Options are suggestions and nothing validates against them, so a person always answers in their own words if they prefer, which is what makes offering them safe.

    Off by default, via SuspensionPolicy.may_ask_questions, and on the Spec rather than the Runtime: an agent that can park a Run for a day is a different agent from one that cannot, so it joins the Version hash.

    RunStatus.pending_question is the sibling of pending_approval, and pending_approval now checks the suspend reason. Without that a question also surfaced as an approval, so a console would have offered Approve and Deny for something that needs words.

    Adds Suspended.questions (defaulted, so existing Records still load) beside the one-line question, which keeps meaning what it did.

  • A provider's own cost is recorded instead of recomputed. compute_cost derived every figure from whatever price table was in force, and that table is documented as incomplete and known to go stale. Several gateways already return a cost computed against the caller's real contract, including negotiated rates Psych has no way to know. That number is better, because it comes from the party doing the billing.

    why

    StreamDone.cost carries it, psych_runtime.model.openai_compat reads it from the three places gateways actually put it (_hidden_params.response_cost, usage.cost, top-level cost) as plain JSON fields with no new dependency, and Runtime.cost_policy decides what gets written: prefer_provider (the default), computed, or provider_only.

    Cost.source is the part that matters. Every recorded cost says whether Psych derived it or a provider reported it, and a total that summed both reads mixed rather than inheriting the provider's authority for arithmetic that is half Psych's. Reconciling against an invoice needs to know which rows came from where.

    cost=None still means unknown and never zero, under every policy. Cost is still resolved once at call time and written into the Record, so changing the policy or the table later cannot retroactively change what a past Run cost.

    Adds source to Cost (defaulted, so existing Records still load) and provider_reported_costs to TotalsReport. The default policy means a deployment behind a gateway starts recording the gateway's figures; a provider that reports nothing is unaffected.

  • Core. Spec and Version models with content hashing, the append-only record log, the pure reducer, thirteen typed corruption errors, Scope, usage and cost types, and the conversation projection.

  • Runtime. The agent loop, the workflow engine with step memoisation, the Worker with leases and deadlines, suspend and resume, interrupts and the three steering queues, subagent delegation, dispatch and streaming.

  • Store. The Store port and four adapters (in-memory, PostgreSQL, MySQL, DynamoDB), all passing one 34-test contract suite against real databases.

  • Tools. The registry, the per-turn resolver, access narrowing, the failure-streak guard, HTTP tools, MCP with (scope, server, credential) pooling, approvals, skills, and large-result elision with a reader tool.

  • MCP OAuth is per server, not per pool. McpServer.oauth (McpOAuth) carries which OAuth 2.1 grant and which client identity one server uses, so a Spec naming two servers behind two different authorization servers -- or one needing authorization_code and another client_credentials -- is served by one McpPool instead of two. Spec-carried rather than runtime configuration keyed by server name, deliberately: see McpOAuth's docstring in psych_runtime/core/spec.py for both sides of that argument. Never a client secret literal -- only a client_secret_credential name, resolved through the same SecretResolver McpServer.credential already uses. This adds a field to McpServer, which moves the Version hash of any Spec that already declares mcp_servers (a Spec with none is unaffected); see tests/unit/test_version.py::TestMcpOAuthHashImpact.

  • Model. The ModelClient port, an OpenAI-compatible SSE adapter, the single egress seam, pricing with an honest cost=None, cache-deliberate prompt assembly, and the transient classifier.

  • Sandbox, memory, telemetry, report, builder, testing. Ports and adapters for each, a span schema with runtime conformance checking, and the scriptable fake model.

  • A conversation can be continued. psych_runtime.dispatch() gained continues=: pass a prior Run's id and the new Run's model calls see that Run's whole conversation, walked back through the chain (psych_runtime.runtime.thread.load_thread_history) and bounded by the new Limits.max_history_records (see Changed, below). Refused with AccessDenied across a Scope boundary, the same discipline as the MCP pool key. RunAdmitted gained continues_run_id to carry the link; it is unrelated to parent_run_id, which stays delegation.

    why

    psych_runtime.send() is new alongside it: DESIGN.md §9's three steering queues (STEER, FOLLOW_UP, NEXT_RUN) were reachable only from psych_runtime.testing.logs.LogBuilder before this, so nothing outside a test could ever put a message into a running Run. send() is that missing write path, through the reducer's own queue_after_abort rule -- STEER and FOLLOW_UP are refused once a Run has aborted, NEXT_RUN is not.

    Fixed a bug this surfaced: AgentLoop._finish_turn checked pending_follow_up after draining it, and draining a queue consumes every entry as a side effect, so the check always saw an empty queue and a pending follow-up could never actually keep a Run going past a turn with no tool calls. Untested before because nothing wrote a FOLLOW_UP entry into a real Run until send() existed to do it.

    RunHeader gained continues_run_id, mirroring the record for a get_run Scope check that does not require replaying a whole log first. Postgres and MySQL adapters need a schema change for it: migration 0002_thread_continuation in both psych_runtime/store/migrations/ and psych_runtime/store/migrations_mysql/.

  • A platform can read a Run without writing the projection itself. psych_runtime.thread() returns one conversation across every Run in its chain, each message carrying the Run id, sequence and timestamp that produced it. psych_runtime.status() returns a frozen, serialisable RunStatus: a lifecycle in the words a screen uses (queued, running, waiting, stopping, done, failed, stopped), the exact call awaiting approval with its arguments, turn and call counts, and the head sequence a stream reconnects from. psych_runtime.state() still returns the reducer's own mutable working object, which is the right shape for the runtime and the wrong shape for a consumer, which is why every consumer was projecting it by hand and each deciding separately what a status is.

    stopping is the addition that matters most: without a word for a Run that has been told to stop and has not settled, a UI shows "running" beside a stop button that appears to do nothing.

  • Deferred MCP tool disclosure (McpServer.preload). A connected server's tool schemas no longer necessarily go into the prompt. A deferred connection contributes three tools instead -- list what a server offers, read one tool's schema, call one tool -- plus one line per deferred server saying it is there, because a tool that silently is not there is DESIGN.md §10.7's own failure. Deferral is a property of a connection rather than an opaque per-call handle: a deferred tool is addressed by a plain (server, tool) pair, resolved through the same narrowing an ordinary call goes through.

    why

    preload is three-valued and defaults to None, deciding from the catalogue: over forty tools defers, fewer preloads. A Spec's author cannot know the count -- it is discovered from the server and it changes -- so an explicit default would be a guess, while True and False pin it for an author who does know. Measured against a real 351-tool server: 2,082,521 request bytes per turn before, 1,911 after, with all 351 still reachable.

    Discovery is a door, not a bypass: list_tools shows a deferred server's permitted names, and call_tool resolves through the same narrowing an ordinary call goes through.

  • MCP pool introspection. McpPool.status() reports each pooled connection (protocol era, tool names, catalogue age and TTL, credential identity, never a secret) and McpPool.probe() makes one real connection attempt under the Scope a Run will actually use, reporting the error's type rather than a boolean. Both exist because the example platform was guessing at connection health from a test call's return value.

  • psych exports what a consumer needs to boot: Worker, Runtime, ToolRegistry, HttpTransport, OpenAICompatibleClient, McpPool, McpTools, DEFAULT_PRICES, ValidationContext. The documented example imported six of them from submodule paths the package docstring calls internal.

  • McpTools(describe_server=...) now takes the Scope. A breaking change to a hook added earlier in this same unreleased cycle. McpTools.describe always had the Scope and passed only the server, so a consumer could not give two tenants different descriptions of the same URL: one tenant's words landed in the other's system prompt. Every other consumer hook here is scope-keyed already (SecretResolver.resolve, tenant_policy, the egress seam); this one was the exception, which made per-tenant descriptions impossible rather than merely awkward.

  • A run splits into its answer and the work behind it (psych_runtime.answer()). A Run that took six turns rendered as six blocks of model text and tool calls, all expanded, and the person who asked where their order was had to read the whole working process to find the sentence answering them.

    why

    The split is derived, never decided. The answer is the turn the loop itself finished on, which DESIGN.md §5 makes its one success condition; the work is every turn before it. Nothing asks the model to classify its own question, which is a judgement it is unreliable at, costs a call, and does not need to make. A one-turn "hi" therefore has no work section at all rather than an empty collapsed box, and a Run that never reached a finishing turn reports finished=False with an empty answer, because there is no answer rather than because the agent said nothing.

    psych_runtime.thread() still returns the flat conversation, unchanged. A transcript and an outcome are different questions and both are worth answering.

  • ModelCallStarted records the system prompt as sent, per turn. psych_runtime.report() used to reconstruct it from the pinned Spec and said so in its own docstring, admitting that memories and runtime advisories were missing because neither was in the log. Every other question a reader asks of a trace was already answerable; "what was it told" was not. Per turn rather than per Run because a tool withheld on turn three means that turn was genuinely told something turn one was not.

    Measured rather than assumed: a real prompt is under 4 KB and its whole record under 100 KB, well inside the 400 KB DynamoDB item limit that constrains every record (DESIGN.md §7). ModelCallReport also gained tool_names, so a report says what each turn was offered.

  • An MCP server can describe itself to the model, without joining any Version hash. An agent wired to three servers saw three names, and with a deferred catalogue the tools were not in the prompt to explain them either.

    why

    The description is runtime data, resolved per turn into the advisories block, and deliberately not a field on McpServer: that model is part of AgentSpec, so a description there would join the Version hash and improving a sentence would republish every agent naming the server. Two sources, in order: McpTools(describe_server=...) from whoever wired the connection, then the server's own instructions from the MCP handshake, which this client had been discarding in both protocol eras. Capped in characters, and a consumer lookup that raises is caught rather than failing the Run over prompt decoration.

    The advisory is one block now rather than two: every connected server gets a line naming it, saying how many tools this Run can reach, whether they are in the tool list, and what the system is for. A Run with everything preloaded and nothing described emits no block at all.

Changed

  • Limits gained repeat_call_threshold and repeat_call_hard_stop, which moves the Version hash of every Spec. A breaking change, and a wide one: Limits is on every AgentSpec, so unlike the McpOAuth addition (which only moved hashes for Specs declaring mcp_servers) this re-hashes everything. Republishing an existing Spec produces a new Version rather than returning the old one. Unavoidable while canonical_bytes serialises every field, including new ones at their defaults.

    why

    The fields exist because a model repeating a call that works had nothing stopping it. Observed against a real provider: thirty-two calls to one tool with byte-identical arguments, every one returning ok, ending in budget_exhausted after 31,120 input tokens for a question a single call answers. The failure-streak guard counts failures, so it correctly never advanced.

  • Limits gained max_history_records, which moves the Version hash of every Spec again, for the same reason and to the same extent as repeat_call_threshold above. Bounds how many records of a continued Run's ancestors a turn replays -- see psych_runtime.runtime.thread and Limits.max_history_records's own docstring for the walk and why it always keeps at least the immediate predecessor whole. Replaying an unbounded chat history into every turn is the dominant cost of a chat agent, and this is the field that keeps it a choice rather than a surprise on the bill; 0 disables it, keeping the chain link in the log without replaying anything.

  • Every read entry point takes an optional Scope and refuses another tenant's Run. DESIGN.md §14 says every entry point takes one and every store query is filtered by it; seven of the nine took a bare run_id. One leaked id -- in a log line, in a URL -- was a whole conversation readable and a pending approval resumable by anyone who had it. Optional, so existing callers keep working; a consumer serving end users passes it.

  • psych_runtime.resume() records who decided. Resumed.resumed_by, threaded from resume(by=...). An approval of a destructive call whose log cannot say who approved it is not an audit trail.

  • Typed errors replace bare ValueErrors on the public surface: RunNotSuspended, RunAlreadySettled, and stream() now raises RunNotFound for an id nobody admitted rather than polling forever. An HTTP layer can map these without matching on message text.

  • Deferral is decided by catalogue size in characters, not by tool count. psych_runtime.tools.deferred deferred a server above forty tools. Count is a proxy for what actually costs money and a poor one: ten tools carrying 50 KB schemas each are far worse for a prompt than eighty tools with three fields apiece, and a count-based rule got that exactly backwards.

    why

    Measured now on the serialised size of the tools this Run may actually see (name, description and input schema), so a Spec allowing six tools from a huge server is not deferred for a catalogue it cannot reach. Runtime.catalogue_budget_chars makes it a deployment's choice.

    Characters, not tokens, and never scaled to resemble tokens. A token count needs a tokenizer, tokenizers differ per provider, and the same figure would then mean different things depending on which model an agent names. Every docstring and field description that shows the number says so. DEFERRED_TOOL_THRESHOLD is removed rather than left beside its replacement.

  • AgentSpec gained answer_style, which moves the Version hash of a Spec that sets it. "concise" asks the agent to lead with the answer, keep it short, and use bullets or a small table when presenting several facts. Never "always use a table", which would make a one-line answer absurd.

    why

    On the Spec, and so in the hash, deliberately: it changes what the model is told, and a Version that does not capture that stops being an honest record of what the agent was (DESIGN.md §4). This is the opposite call from the MCP server description above, and the distinction is the reason. A response style is part of what the agent is and changes only when someone changes the agent; a server description is a fact about an external system that moves on its own schedule.

    None is the default and renders nothing at all, so every already-published Version assembles byte-identically and nothing changes behaviour.

Fixed

  • Interrupting a Run a Worker was executing failed it instead of stopping it. psych_runtime.interrupt() appends through its own Journal, so the Worker's next append lost the sequence, raised SeqConflict, escaped every handler, and the last-resort path settled the Run FAILED with "the attempt raised". A Journal now folds the records a consumer is allowed to append (an abort, the three queues, a resume) and retries once; anything else still propagates, because that is a second Attempt and interleaving two of them into one log is the bug this rule exists to prevent. The loop folds at every turn and after every tool call, so a stop lands mid-turn rather than at the end of one.

    This is DESIGN.md §23 item 3, which the e2e suite appeared to cover: every interrupt test interrupted a Run before any Worker claimed it, so the path a person actually takes was never exercised.

  • A graceful Worker.stop() settled every in-flight Run as "passed its deadline", and a Worker that lost its lease wrote a terminal record into a log another Worker already owned -- a second writer, which is precisely what LeaseLost exists to prevent. The abort signal carries a reason now (psych_runtime.runtime.abort): a deadline settles, a shutdown hands the Run back RUNNABLE for the next Worker to continue, a lost lease writes nothing.

  • A suspension mid-turn left the remaining tool calls unanswered. The loop returned on the suspension, so an assistant message carrying two tool calls could reach the next prompt with one result. Providers reject that and the conversation is unrecoverable (DESIGN.md §9). The calls a turn will no longer run are now answered explicitly.

  • Runtime held per-Run state on a shared instance. _parent_scope and _parent_run_id were written on every __call__ and read much later by _delegate, so with two concurrent Runs a delegation could be dispatched under the other tenant's Scope, with that tenant's credentials. They travel as arguments now. The docstring saying "holds no per-Run state" is true.

  • A subagent's child Run was claimable by any Worker while its parent executed it inline. Admitted NESTED now, a state claim() never selects.

  • A Run's deadline consumed the time it spent waiting for a person. An approval left pending overnight aborted the Run the moment it was claimed again. RunStateView.effective_deadline_at pushes the deadline back by every second spent suspended.

  • Tool resolution failures escaped as "the attempt raised". A required MCP server that did not answer, or a missing credential, reached the Worker's last-resort handler and settled with a fixed string; the real cause was only on the Worker's stdout, which a platform built on Psych cannot show its users. The loop settles these itself now, naming the server and the reason, and the last-resort handler carries the exception's type, message, cause chain and traceback.

  • MemoryStore did not JSON round-trip records, so a tool result holding a Decimal, a datetime or a Pydantic model reached the model as a Python repr in memory and as JSON from the three real adapters. DESIGN.md §23 item 8 says one Spec runs identically on all four; it did not.

  • An MCP server's in-band isError was recorded as a successful call -- invisible to the failure-streak guard, counted by the repetition guard as an identical success. It raises now.

  • The reducer accepted a model call that finished without starting, which the report then KeyErrord on, and summed costs across currencies. Both are typed corruption now (the second as inconsistent_cost).

Security

  • Idempotency keys were global, not per tenant. Keys are chosen by the consumer and collide across tenants by nature: an order number, a webhook delivery id. Every adapter made them unique across the whole table, so the second tenant to use one got the first tenant's run_id back from create_run -- and every read entry point took a bare run_id. A guessed key was a cross-tenant read of a whole conversation and a resume of someone else's pending approval. The key is (tenant, key) in all four adapters now, with a forward-only migration (0003_tenant_scoped_idempotency) for Postgres and MySQL, and the contract suite asserts both halves.

  • The egress seam had no timeouts. HttpTransport built its client with timeout=None, which disables every one, so a tenant-controlled MCP server that accepted a connection and then said nothing held a turn open until the Run's deadline while the heartbeat kept it pinned. Real per-phase bounds now, an explicit timeout on every MCP request, the policy asked about the URL including its query string (a rule written against a query parameter matched nothing before), and a client configured to follow redirects is refused, because a redirect reaches a host the policy never evaluated.

  • run_code host bindings bypassed Policy, approval selectors, narrowing and the record log. DESIGN.md §18 promises the opposite in as many words: "no privileged back door". A model that could not call issue_refund directly (an approval selector caught it) could call it from a program, unapproved and unlogged. Bindings route through the loop now, so a program's call is gated and recorded exactly like the model's own.

  • The subprocess sandbox ran the child as the worker's own uid whenever the worker was not root, which is the recommended posture. A same-uid child reads /proc/<ppid>/environ, so every key the worker held was one open() away; verified on this host. Refused now unless the caller passes allow_same_uid. And network=False silently meant "network" wherever unshare(CLONE_NEWNET) needs a capability the worker lacks: the child reported the truth and nothing read it. require_network_denial fails the execution instead.

  • read_tool_output ran a model-written regular expression on the event loop, over a stored result of any size. Python's re has no timeout, so one catastrophic-backtracking pattern stalled every other tenant's Run in the process along with the lease heartbeat. The subject is capped before matching and the read runs in a worker thread.

  • A host tool's traceback was replayed into the model's context. DESIGN.md §18 asks for that for a sandboxed program, which the model wrote and can fix. A host tool's traceback is the consumer's own code and carries their paths and whatever the exception embedded, an asyncpg DSN among them. The log keeps it for an operator; only run_code marks one as the model's to read.

  • An MCP tool annotated without a destructiveHint was read as a write. The MCP specification defaults that hint to true. Reading it as a write let a consumer who narrowed to @destructive wave through exactly the calls the annotation exists to catch.

  • A workflow ToolStep executed without consulting Policy. It goes through the same gate as an agent's tool call now.

The example platform

examples/playground is the worked answer to "what does a consumer build". It moved with the library this release.

  • Memory reaches the console (DESIGN.md §15). The runtime has had durable facts all along: a MemoryStore port, remember/forget as built-ins, and injection into every later prompt. The console wired none of it, so a person evaluating Psych through it would have concluded memory did not exist.

    why

    Two decisions worth recording. The console writes its own file-backed adapter rather than using StoreBackedMemory, which keeps facts on the instance: a memory that empties when the process restarts would demonstrate the API and disprove the feature. And end_user_id is passed explicitly at dispatch rather than left to fall through to Scope.principal, which in this console is the same value. Falling through would have produced the same behaviour with nobody able to tell a decision from a default, and it now lands on RunAdmitted.input, so a trace says whose memories a Run read.

    Settings shows what agents remembered and can forget one or erase all of it. Erasure is the operation §15 exists for -- a consumer's own customers will ask them to delete their data -- so it is a first-class button rather than a loop over forget that half-finishes if a tab closes.

  • Skills reach the console (DESIGN.md §16). The runtime has had them all along: AgentSpec.skills, a [[skill:name]] link graph validated at publish, load_skill as a built-in, and the index in the prompt while bodies stay out of it. None of that was reachable from the console, whose agent form had no field for it, so a person evaluating Psych through the console would have concluded skills did not exist.

    why

    The form teaches the split rather than just collecting it, because getting it backwards is silent and expensive: the description is charged for on every turn and the body only when the model asks, so each field carries a live character count saying which it is. A dangling [[skill:x]] link is refused at publish and the error lands on the body that wrote it, naming the link and listing what the agent does have.

    A new capability scenario, skills-on-demand, measures the claim instead of asserting it: 310 characters of instructions stayed out of a 311-character prompt until the model called load_skill. It also covers the two behaviours a naive implementation reverses -- asking twice returns the body again rather than a bare reminder, and an unknown skill name is a tool result rather than a failed Run.

  • The console has accounts, and two people's data cannot collide. Sign up, sign in, sign out. Everything you publish, connect, configure or say is yours: another account on the same backend cannot list it, cannot open it by naming its id, and cannot use your provider key or your MCP credential.

    why

    Two holes are closed. POST /api/runs built its Scope from a tenant field in the request body, so any caller could name any tenant and read anybody's Runs; the field is gone, not validated, because a field that exists is one somebody eventually trusts. And the settings and index files had no owner dimension at all, so one set of provider keys and MCP credentials was shared with whoever loaded the page next.

    Psych needed nothing for this, which is the point. Scope(tenant, principal) was already on every entry point; §1 refuses identity by name. app/accounts.py and app/auth.py are the consumer's half, and they are the shortest answer to "how do I put my own users in front of Psych".

    Passwords use hashlib.scrypt at OWASP's floor, so no new dependency: measured at 770ms to hash and 430ms to verify, once per sign-in rather than per request. Only the hash of a session token is stored.

    Authorization reads RunHeader.scope from Psych's own Store rather than the playground's index, because the header cannot drift. A run id is not a secret, so "not yours" answers with the same 404 as "does not exist".

    Deliberately absent: password reset, email verification, OAuth sign-in, roles, invitations, shared workspaces.

  • Two accounts publishing an identical Spec each keep their own agent. A Version is its content hash, so byte-identical Specs are one Version, which is correct. The console's index is keyed by (owner, version_hash): keyed by hash alone, the second publish overwrote the first's entry and one person's delete removed the other's agent.

  • A live MCP connection is no longer shown to the wrong account. The pool is process-wide; keyed by server name alone, one person's connected server appeared as connected under another person's preset of the same name.

  • A model client per account, built per Attempt. Each account configures its own provider, so a Run must reach the endpoint its owner configured. The router used to cache one Runtime per approval-selector tuple and mutate its model in place, which with one global provider was safe and with a provider per account is a race with a credential in it: the loser sends its prompt to another tenant's endpoint with another tenant's key. Building fresh also deletes the invalidation problem.

  • A 401 lost its CORS headers. Starlette's add_middleware inserts at position 0, so the session check was registered outside CORSMiddleware and short-circuited before any header was attached. A browser then refused to read a perfectly correct response, which presents as "cannot reach the backend" while the server log shows the request arriving and being answered.

  • Signing in appeared to work and then did nothing. The session cookie is SameSite=Lax, and a browser treats localhost and 127.0.0.1 as different sites, so a console served on one and a backend on the other never exchanged it. The console now uses the page's own spelling of loopback, and confirms the session against /api/auth/me after signing in rather than trusting the response body.

  • Every route is guarded, and a test says so. Authentication runs in middleware, ahead of body parsing, so an anonymous POST no longer reaches Pydantic and gets a schema-shaped 422 back. A test reads the routes off the live app and requires each to either demand an account or appear on a five-entry public list with a written reason, so the endpoint added next year fails there instead.

  • Runs dispatched before accounts existed are no longer listed. Their RunHeader.scope names a tenant nobody can sign in as, and a header is not the index's to rewrite, so listing them would show rows that open into a 404. Records are untouched in the store and the boot log says so. Settings and agents from before accounts are adopted by the first account created.

  • The answer is the only thing set in reading type. A run that made four tool calls used to put all four on the screen at the same weight as the sentence answering the question. The work is one collapsed line now ("2 turns, 2 tool calls") over the answer, and opening it gives an accordion of tool calls with arguments, results, outcome and duration. The answer is rendered Markdown, so an agent told to use a table gets a table instead of a screenful of pipes. Raw HTML is deliberately not enabled: model output is downstream of tool results, which are downstream of whatever someone put in a record.

  • Every model call shows what it was told. The trace carries the resolved system prompt per turn, the user's input, the model's narration, each tool call and its result. "Why did it do that" was previously a question the console could not answer.

  • The conversation list sits on the right and the nav collapses to icons. The conversation is the leftmost thing on the screen next to the app's own nav; history is a place to go back to rather than something to read past on the way in. The nav rail collapses to 48px with a button in its footer, which on desktop previously did not exist (the only trigger was md:hidden, so the sidebar could collapse and nobody could ask it to). Conversations is also a full page of its own, sharing buildThreads() with the panel so the two lists cannot disagree about where one conversation ends.

  • The panel headers line up. The conversation column, the chat header and the history panel each set their own height and padding, so three bars that read as one bar were off by a pixel or two at every seam. One .bar class owns it now.

  • The console is five surfaces instead of six screens in the runtime's vocabulary: Chat, Agents, Connections, Activity, Settings, with the capability suite in its own group. Chat and Runs had listed the same conversations two contradictory ways (one row per thread, one row per turn); Tools was a read-only registry dump. Run ids, version hashes and sequence numbers now sit behind one disclosure with the same label on every surface, and are first-class only on Activity, where reading them is the point.

  • Connections is its own page and remembers what it found. A server you had just connected showed a "Connect" button again after a refresh: the result lived in component state and the tab holding it unmounted. The backend persists the connection record now (last_connection), and exposes the pool's live view beside it (live), so a connected server still reads as connected across a restart. Its catalogue is searchable, which a 351-tool server needs.

  • A published agent carries what its connection needs. transport and, for an authorization_code grant, this process's own OAuth callback were both dropped between the settings page's connection test and the Spec that got published. So a connection tested green and then failed inside every Run.

  • The backend has tests (tests/playground/), including the one its own README pointed at: a stored API key and a secret value never appear in any response body.

  • event: done means the Run is over. The stream endpoint wrapped its record iterator in a timeout, which cancelled the generator on the first idle period and ended the stream on a Run that had merely been quiet. A suspended approval, a slow model or an OAuth wait all look like that, and a client treats done as terminal, so the page froze until a reload.

  • Removed examples/playground/frontend, the original plain-HTML console. Superseded by the Next.js one, referenced by nothing, and speaking a contract three features out of date.

Removed

  • McpServer.transport no longer accepts "stdio". A breaking change to the public API. The enum member advertised a transport that had no implementation behind it: a Spec could declare it, publish-time validation passed, the Version was stored, and the connection could never be made.

    why

    The reason matters, because it is the opposite of what a reader would assume. stdio is not deprecated by the MCP specification. It is the fully supported local-subprocess transport, and it is what most MCP servers people run on their own machine actually use. It was removed here because Psych never implemented it: no subprocess launcher, no line-delimited JSON-RPC over stdin and stdout, nothing. That is the placeholder-on-a-shipped-path the repository forbids.

    Adding a real stdio transport is open work rather than a silent revival of the member. Note for whoever does it: the spec says a stdio client SHOULD NOT use the OAuth flow and takes credentials from the environment instead, so it must not be wired to the authorization path.

Known limitations, stated rather than discovered

  • Network denial in the subprocess sandbox is best-effort, not a floor. It attempts unshare(CLONE_NEWNET) and the child self-verifies the result, which SandboxResult.network_denied reports honestly. An unprivileged worker will usually see unshare fail, and the adapter runs the program anyway rather than refusing. Use the container backend when denial must be guaranteed.
  • RLIMIT_NPROC is not enforced for uid 0. The kernel exempts root, so the subprocess adapter drops the child to nobody when the host runs as root. That makes the process-count limit per-uid rather than per-execution under concurrency.
  • The container backend is untested against a live daemon. It is fully implemented and its tests run wherever a runtime exists; this environment has none, so 13 tests skip and say so. The docker inspect exit-code and OOMKilled conventions it relies on are documented behaviour, not verified here.
  • The default MemoryStore adapter does not persist across a restart. The Store log is append-only with no delete, and forget must actually delete, so the shipped adapter keeps its own in-process table. A consumer wanting durable memory implements the same four-method port against their own table.

Decisions that diverge from DESIGN.md

Each is documented at the place it happens. The list includes lease renewal (§8.2), the failure-streak guard's scope (§10.6), spec versioning's canonicalisation rules (§4), approvals for unannotated tools (§10.9), and the corruption taxonomy's membership (§6).

Documentation is versioned by minor and cut from the tree that tracks main. Read the docs