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.

Unreleased

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