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
-
MemoryStorethe adapter is nowInMemoryStore, andMemoryBlobStoreisInMemoryBlobStore.psych_runtime.store.memory.MemoryStoreandpsych_runtime.memory.port.MemoryStorewere 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.
InMemoryStorematchesInMemorySecretResolverandInMemoryAuthorizationRedirect, already in the codebase.MemoryBlobStorehad 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.InMemoryStoreandpsych_runtime.InMemoryBlobStore. -
The import is now
psych_runtime, matching the distribution.psychis 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 psychbecomesimport psych_runtime, and every attribute follows. The command line stayspsych, 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, andpyproject.tomlreads 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 ispsych_runtime.psychwas 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.psychstays 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 asanswer()is. It ships for the reasonanswer(),status()andthread()ship: every consumer writes this loop and every one gets the same three things wrong. Text arrives onmodel_call_finishedrather 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 raisesRunAbortedcarrying the terminal state, so a user's stop is distinguishable from a deadline. Both subclassRunEndedWithoutAnswer. 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_DSNmeasures 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 aDeprecationWarningthat names its replacement; deprecated in 0.N means removed no earlier than 0.N+2; the changelog gets aDeprecatedsection. 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 becausetests/unit/test_public_surface.pyalready 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 nopsych_runtime.syncand there will not be one:asyncio.run()builds a fresh loop per call and anasyncpgpool 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 overWorkeris 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 inweb/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.combecomes a redirect topsychruntime.com/docs.The part that matters is not the pages. It is that almost nothing in them is written twice.
reference/*is generated frompsych_runtime.__all__byscripts/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 byweb/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 isCHANGELOG.md.scripts/check.shand a CI job run all of it with--checkand 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.mjsdoes 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 inweb/site/content/site/andlib/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 everypsych_runtimesymbol in a sample against__all__.The Trident is the brand mark, drawn once in
components/Trident.tsxand 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.0freezescontent/docs/nextas a released tree, rewriting its own links to the new version as it copies. It refuses when__version__does not already say0.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.pyprints 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 tourgenerates 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 separateworker.pythat executes them, and anagent.pyboth 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 aStoreand nothing else. With noPSYCH_POSTGRES_DSNset,main.pyhosts a Worker itself andworker.pyrefuses to start rather than claiming from an in-memory store no other process can see.psych skills installcopies the 26 guides into a consumer's own.agents/skills/, which is why.agents/skillsis now force-included in the wheel atpsych_runtime/_skills; the canonical copy stays where this repository's own agents already find it.psych doctorreports 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 newwrites amain.pythe 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 bytests/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, aRuntimeand a runningWorker, gives youawait 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,runtimeandworkerare 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:RunReportwas exported andToolCallReportwas not, so annotating a function that takes one meant importing a submodule path the same docstring calls internal.WorkflowSpecwas exported without the three step types it cannot be constructed without.why
Added: the workflow step types, the two fluent builders (
psych_runtime.agent()andpsych_runtime.workflow(), documented as an authoring form and previously reachable only throughpsych_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.pypins the rule mechanically, so it cannot drift back.One name stays off the surface.
psych_runtime.store.memory.InMemoryStoreandpsych_runtime.memory.port.MemoryStoreare 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 apsychrouter 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 isNoneand never0. 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.mdalready 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.pycovers 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.
Runtimehas always taken aTelemetryand 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.observabilitybuilds 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_ENDPOINTset means export over OTLP/HTTP, unset means the no-op.docker compose upsets 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/configreports 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, whoseotelextra 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_instructionsis 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.latencydoes not exist; it isreport.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 buildfailed on aCOPY LICENSEfor 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_runtimefailed becauseuv syncinstalls the project editable, pointing the copied venv at a/srcthat exists only in the build stage; andimport aioboto3failed because the image syncs--extra playground, which carries asyncpg alone whileapp/scenarios/four_stores.pyimports all four store adapters at module scope.why
The last one is not a dependency at all. Two scenarios imported
McpStubServerfromtests/functional/test_mcp.py, and.dockerignorekeepstestsout of the build context on purpose, so the backend imported a package that could not be there whatever was installed. The stub server moves topsych_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
SubprocessSandboxrefuses 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 passallow_same_uid=True, which is inert wherever a privilege drop is available, andTestSameUidIsRefusedstill proves the refusal fires without it.The container tests were worse than skipped. A runner has
docker, so they ran;ContainerSandboxnever 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
LICENSEand noLICENSEfile has ever existed in this repository, thoughpyproject.toml, the README andNOTICEhave 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 withPostgresStore.migrate()and was not: Postgres records applied versions inpsych_schema_migrationsand 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.shrunsmariadbdunder the name "mysql", and MariaDB accepts the statement. The adapter now keeps the same ledger Postgres does,0002_thread_continuation.sqlis plainALTER TABLE ... ADD COLUMNmatching the Postgres file statement for statement, and the test suite calls the shippedmigrate()instead of its own second copy of it.test_migrate_is_idempotentexists 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.
ContainerSandboxbind-mounts a directory and listens on a Unix socket inside it for the container's bootstrap to connect back. The directory waschmod 0755; the socket was left at whatever the umask gave it, 0755 owned by the worker. The container runs--user 65534:65534by default, andconnect(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 is0666now, because it is reached by an account the worker does not control, and the directory drops to0711so 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_secondsbeside "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.CompactionAppliedcarriestimingsandLatencyReportgainscompaction_seconds, counted apart frommodel_secondsfor the reason the call count is counted apart frommodel_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.compactionis 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
psychlogger had noNullHandler, so an application that had configured no logging still got Psych's lease-renewal warnings via Python'slastResorthandler. 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:3000and there is a console, with no Python, no Node, no database and no model key to arrange first.docker compose upis the same image with real PostgreSQL behind it.why
The ticket originally weighed
uvxagainstnpxand 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 underpsych_runtime/gained an HTTP server or a UI, and nobody should conclude from adocker runthat it did.The container publishes one port. The console now proxies
/apiand/a2ato the backend on loopback inside it, which deletes a whole class of problem rather than solving it:NEXT_PUBLIC_PSYCH_APIis 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 andSameSite=Laxcookies 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}/branchkeeps theconversation_idand takes a newbranch_id, and carries noagent_id: a conversation is with one agent for its whole life.POST /api/runs/{run_id}/forktakes a new one of each and keeps the agent picker, because putting one question to two agents is the reason to fork.RunEntryandRunSummarygainconversation_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/2arrows 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 -ddeletes 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_tasksandshow_componentare reserved. They are registered by Psych and were missing fromRESERVED_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.pynow asserts both directions, because the set has to be written out by hand:psych_runtime.coreimports nothing frompsych_runtime.tools, so it cannot be built from the constants. -
A resumed Run no longer claims it recovered from a crash.
AttemptStarted.reclaimed_expired_leasewasattempt_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 newRunStateView.resumed_since_attemptfolds 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_tokenshas 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_startedand no model-call pair, which the reducer would rightly refuse outside a turn. Its usage and cost ride on theCompactionAppliedrecord 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 recordscost=Nonelike 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_tokenshas 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 toCompactionPolicyto 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 ordinarypsych_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}/forkis that dispatch, and it may name a differentagent_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_idcannot 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.RunEntryandGET /api/runsnow carry abranch_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.Storegains 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.componentsadds 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 ashow_componentbuilt-in, gated on the newAgentSpec.components_enabledand off by default. Each call appends aComponentShownrecord; 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 onRunStatusandRunReport.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_urlandhrefvalidate to http or https and refuse everything else, includingjavascript:anddata:, because those two fields are the only ones a renderer follows. Every string is capped. Parsing is lenient in the same placesupdate_tasksis, 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.a2ais the protocol and nothing else: the data model as validated models ported fromspecification/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 anAgentSpec, RFC 8785 canonicalisation and JWS signing, and push-notification payloads. It imports onlypsych_runtime.core.The centre of it is a pure mapping from a Run to a Task.
RunIdistaskId, which §3.4.2 requires to be server-generated and Psych already mints. Thecontinues_run_idchain iscontextId, which §3.4.1 defines as what "logically groups multiple related Task and Message objects" and is exactly what a continuation chain is.SuspendReason.QUESTIONisTASK_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 isTASK_STATE_AUTH_REQUIRED.psych_runtime.answer()becomes anArtifactrather than a Message, because §3.7 says outputs are artifacts. Streaming order (§3.5.2) is free: the log is gapless and ordered, andstream_eventsis a loop over it.psych_runtime.tools.a2ais 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-offMcpOAuthdocumented 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
CardSignera consumer supplies: ES256 needscryptography, 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
SubagentRefis still embedded in its parent's Spec, still reached through a blockingdelegatecall, 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
AgentSpecmay now carry aSpawnEnvelope: 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.narrowingthe 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
RUNNABLEwith 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 newSuspendReason.CHILDRENrather 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 reusingexternalbecause 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, joiningpsych_runtime.runtime.journal.EXTERNAL_RECORD_TYPESbeside 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_subagentandmessage_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 aSubagentRefdescription 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()gainssubagentsandsubtree.totalsstill covers one Run alone and still says so;subtreeis that Run plus every descendant, summed with the sameUsage.__add__andCost.__add__a single Run's totals use, so an unpriced call stays out of the sum rather than entering it as zero. It carriescomplete, 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.executereloads 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
AgentPointerbeside its Version entries: a stableagent_idthat 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/agentstakes an optionalagent_idand moves that agent to what it publishes;GET /api/agentsreturns one row per agent;GET /api/agents/{id}/versionsis the history;DELETE /api/agents/{id}takes an agent id where it took a version hash.POST /api/runstakes either anagent_id, resolved once at admission, or aversion_hashto 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_idbesideversion_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.AgentEntryalso carriesmay_ask_questionsandtasks_enablednow. 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 nowDELETE /api/agents/{agent_id},DispatchRequest.version_hashis optional and exactly one of it andagent_idmust be set, andcreatedin 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 noagent_idbecomes 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.A2APeershipped 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/agentsnow takesa2a,PUT /api/settings/a2asaves 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.
descriptionis 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
PlaygroundStatebeside 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/skillsandSettingsResponse.skills. -
An agent can stop and ask.
SuspendReason.QUESTIONandSuspensionPolicy.question_expires_secondshad existed since the suspension machinery was built with nothing able to reach either; onlyAPPROVALwas ever raised.ask_questionis 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_questionis the sibling ofpending_approval, andpending_approvalnow 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-linequestion, which keeps meaning what it did. -
A provider's own cost is recorded instead of recomputed.
compute_costderived 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.costcarries it,psych_runtime.model.openai_compatreads it from the three places gateways actually put it (_hidden_params.response_cost,usage.cost, top-levelcost) as plain JSON fields with no new dependency, andRuntime.cost_policydecides what gets written:prefer_provider(the default),computed, orprovider_only.Cost.sourceis the part that matters. Every recorded cost says whether Psych derived it or a provider reported it, and a total that summed both readsmixedrather 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=Nonestill 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
sourcetoCost(defaulted, so existing Records still load) andprovider_reported_coststoTotalsReport. 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
Storeport 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 needingauthorization_codeand anotherclient_credentials-- is served by oneMcpPoolinstead of two. Spec-carried rather than runtime configuration keyed by server name, deliberately: seeMcpOAuth's docstring inpsych_runtime/core/spec.pyfor both sides of that argument. Never a client secret literal -- only aclient_secret_credentialname, resolved through the sameSecretResolverMcpServer.credentialalready uses. This adds a field toMcpServer, which moves the Version hash of any Spec that already declaresmcp_servers(a Spec with none is unaffected); seetests/unit/test_version.py::TestMcpOAuthHashImpact. -
Model. The
ModelClientport, an OpenAI-compatible SSE adapter, the single egress seam, pricing with an honestcost=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()gainedcontinues=: 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 newLimits.max_history_records(see Changed, below). Refused withAccessDeniedacross a Scope boundary, the same discipline as the MCP pool key.RunAdmittedgainedcontinues_run_idto carry the link; it is unrelated toparent_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 frompsych_runtime.testing.logs.LogBuilderbefore 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 ownqueue_after_abortrule --STEERandFOLLOW_UPare refused once a Run has aborted,NEXT_RUNis not.Fixed a bug this surfaced:
AgentLoop._finish_turncheckedpending_follow_upafter 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 aFOLLOW_UPentry into a real Run untilsend()existed to do it.RunHeadergainedcontinues_run_id, mirroring the record for aget_runScope check that does not require replaying a whole log first. Postgres and MySQL adapters need a schema change for it: migration0002_thread_continuationin bothpsych_runtime/store/migrations/andpsych_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, serialisableRunStatus: 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.stoppingis 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
preloadis three-valued and defaults toNone, 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, whileTrueandFalsepin 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_toolsshows a deferred server's permitted names, andcall_toolresolves 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) andMcpPool.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. -
psychexports 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 theScope. A breaking change to a hook added earlier in this same unreleased cycle.McpTools.describealways 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=Falsewith 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. -
ModelCallStartedrecords 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).
ModelCallReportalso gainedtool_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 ofAgentSpec, 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 owninstructionsfrom 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
-
Limitsgainedrepeat_call_thresholdandrepeat_call_hard_stop, which moves the Version hash of every Spec. A breaking change, and a wide one:Limitsis on everyAgentSpec, so unlike theMcpOAuthaddition (which only moved hashes for Specs declaringmcp_servers) this re-hashes everything. Republishing an existing Spec produces a new Version rather than returning the old one. Unavoidable whilecanonical_bytesserialises 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 inbudget_exhaustedafter 31,120 input tokens for a question a single call answers. The failure-streak guard counts failures, so it correctly never advanced. -
Limitsgainedmax_history_records, which moves the Version hash of every Spec again, for the same reason and to the same extent asrepeat_call_thresholdabove. Bounds how many records of a continued Run's ancestors a turn replays -- seepsych_runtime.runtime.threadandLimits.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;0disables it, keeping the chain link in the log without replaying anything. -
Every read entry point takes an optional
Scopeand 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 barerun_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 fromresume(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, andstream()now raisesRunNotFoundfor 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.deferreddeferred 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_charsmakes 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_THRESHOLDis removed rather than left beside its replacement. -
AgentSpecgainedanswer_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.
Noneis 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 ownJournal, so the Worker's next append lost the sequence, raisedSeqConflict, escaped every handler, and the last-resort path settled the RunFAILEDwith "the attempt raised". AJournalnow 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 whatLeaseLostexists to prevent. The abort signal carries a reason now (psych_runtime.runtime.abort): a deadline settles, a shutdown hands the Run backRUNNABLEfor 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.
-
Runtimeheld per-Run state on a shared instance._parent_scopeand_parent_run_idwere 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
NESTEDnow, a stateclaim()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_atpushes 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.
-
MemoryStoredid not JSON round-trip records, so a tool result holding aDecimal, adatetimeor 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
isErrorwas 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 asinconsistent_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_idback fromcreate_run-- and every read entry point took a barerun_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.
HttpTransportbuilt its client withtimeout=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_codehost 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 callissue_refunddirectly (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 oneopen()away; verified on this host. Refused now unless the caller passesallow_same_uid. Andnetwork=Falsesilently meant "network" whereverunshare(CLONE_NEWNET)needs a capability the worker lacks: the child reported the truth and nothing read it.require_network_denialfails the execution instead. -
read_tool_outputran a model-written regular expression on the event loop, over a stored result of any size. Python'srehas 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
asyncpgDSN among them. The log keeps it for an operator; onlyrun_codemarks one as the model's to read. -
An MCP tool annotated without a
destructiveHintwas read as a write. The MCP specification defaults that hint to true. Reading it as a write let a consumer who narrowed to@destructivewave through exactly the calls the annotation exists to catch. -
A workflow
ToolStepexecuted without consultingPolicy. 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
MemoryStoreport,remember/forgetas 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. Andend_user_idis passed explicitly at dispatch rather than left to fall through toScope.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 onRunAdmitted.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
forgetthat 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_skillas 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 calledload_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/runsbuilt itsScopefrom atenantfield 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.pyandapp/auth.pyare 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.scryptat 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.scopefrom 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
Runtimeper approval-selector tuple and mutate itsmodelin 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_middlewareinserts at position 0, so the session check was registered outsideCORSMiddlewareand 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 treatslocalhostand127.0.0.1as 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/meafter 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.scopenames 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, sharingbuildThreads()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
.barclass 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.
transportand, for anauthorization_codegrant, 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: donemeans 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 treatsdoneas 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.transportno 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, whichSandboxResult.network_deniedreports honestly. An unprivileged worker will usually seeunsharefail, and the adapter runs the program anyway rather than refusing. Use the container backend when denial must be guaranteed. RLIMIT_NPROCis not enforced for uid 0. The kernel exempts root, so the subprocess adapter drops the child tonobodywhen 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 inspectexit-code andOOMKilledconventions it relies on are documented behaviour, not verified here. - The default
MemoryStoreadapter does not persist across a restart. TheStorelog is append-only with no delete, andforgetmust 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