Skip to main content

Prism Harness for Python

Durable agent sessions — threads, session state and store drivers. The Python port of particle-academy/prism-harness.

Zero runtime dependencies. Python 3.10+.

pip install prism-ai-harness
from prism_harness import FileSessionStore, MemorySessionStore, Participant, PrismHarness

harness = PrismHarness(
    drivers={
        "memory": MemorySessionStore,
        "files": lambda: FileSessionStore("./storage/harness"),
    },
    stores={"ephemeral": "memory", "durable": "files"},
)

session = harness.for_(Participant("User", 7)).session("support")
session.using_mode("plan").using_model("claude-sonnet-4-5")


def advance(live):
    live.begin_run("run-1", "plan", "anthropic", "claude-sonnet-4-5")


session.lock(advance)  # whatever must not happen twice

for is a Python keyword, so the method is for_ — forced rather than chosen, like Media.as_ in prism-ai-core.

Resolved, never held

A server handles a request and moves on, so a session cannot be an object kept in memory the way a single-process agent's is. Every call rebuilds one from a store, which is what makes a fresh worker see the same mode, model and conversation as the request that set them.

The two halves

Slot Holds Losing it means
ephemeral active mode, selected model, run bookkeeping falls back to a default
durable threads, stored capabilities work is gone

A store that reports itself volatile is REFUSED for the durable slot, at the moment a session is opened. That is the guard the package exists for: a cache is disposable by definition, and the durable slot holds approvals a human has not answered yet.

Construct a PrismHarness with no drivers and it works — and then refuses durable state, loudly, with a message that names the fix. A package that silently accepted an in-memory durable store would pass every test in one process and lose a half-executed action the first time it ran on two.

The same address as PHP

Session.key() is byte for byte what the reference builds: session: plus the sha1 of the participant type truncated to 12, the participant id, and the scope. Matching exactly is what lets a PHP app and a Python agent share one store and resolve the same session.

Threads

record() assigns positions inside the store lock, reading the current length and writing the new messages as one operation. Two turns landing concurrently would otherwise both read position 4 and both write position 5, silently losing a message — the race the reference tracks as prism-harness#2.

Attachments

A turn can carry media alongside its prompt:

from prism import Image

runtime.send(
    session,
    "What is wrong with this layout?",
    additional_content=[Image.from_base64(screenshot, "image/png")],
)

Pass prism-ai-core media objects or media already serialized. Attachments are stored with the turn in the shape UserMessage.from_dict() rebuilds. Each one must be an image, document, audio or video that carries its bytes, a provider file id or document chunks. The same rules as the PHP reference and the TypeScript port apply, pinned across all three by prism-parity's harness-turn-attachments corpus.

Refused, with a HarnessError whose code names the problem:

Code When
attachment_by_reference built from a URL or read from a file
attachment_not_media anything other than those four media types
attachment_empty no bytes, no file id and no chunks
attachment_without_prompt attachments with an empty prompt

A refusal happens before a run starts: no run, no events, nothing in the thread.

Provider options per mode

A mode can declare provider_options. They reach your model client unchanged, as request.provider_options, on every step of every run in that mode:

ModeRegistry({"modes": {"overseer": {"provider_options": {"thinking": {"type": "adaptive"}}}}})

A value that is not a map is refused when the mode is resolved, with mode_malformed.

What your model client receives

request.messages is the thread, oldest first, in the shape the PHP reference stores Prism's messages:

type Carries
user content, and additional_content when the turn has attachments
assistant content, tool_calls (id, name, arguments, result_id, reasoning_id, reasoning_summary), additional_content, tool_approval_requests
tool_result tool_results (tool_call_id, tool_name, args, result, tool_call_result_id, artifacts), tool_approval_responses

Consecutive tool result rows reach the client as one, so each call's result is sent to the provider once. Return a call's provider ids on LlmToolCall (result_id, reasoning_id, reasoning_summary), and provider state for the turn as LlmResponse.additional_content: they are recorded and come back in messages.

Approvals

A mode names the tools a person must approve:

"guarded": {"system_prompt": "...", "tools": ["read", "delete"], "requires_approval": ["delete"]}

A step that calls one stops with finish_reason == "awaiting_approval". The calls that need nobody have already run. Record a decision for every pending approval, then resume with an empty prompt:

response = runtime.send(session, "Clean up the failed run")

if response.finish_reason == "awaiting_approval":
    for pending in response.pending_approvals:
        record_approval(session, pending.id, True)  # or False, "not on production"

    runtime.send(session, "")

On resume the approved calls run once and denied calls return their reason, and then the model continues. The model is not asked to make the calls again. A pending call with no decision is refused with "No approval response provided". A call that has a result never runs again. Who may approve is your application's decision: authorize before calling record_approval().

Task lists

An agent given a goal keeps working across many requests. session.tasks() is the list of what remains, in the durable half:

tasks = session.tasks()
tasks.add_many(["Read the brief", "Draft the reply", "Check the numbers"])

while (task := tasks.claim("worker-1")) is not None:
    # The APPLICATION releases, from evidence -- not the agent.
    worked = do_the_work(task.instruction)
    tasks.release(task, "worker-1", TaskOutcome.DONE if worked else TaskOutcome.FAILED)

Four states — todo, claimed, done, failed — and no others, behind four methods: claim, release, pending and find. find is on the contract because release takes a task while every caller outside the loop — a tool, an HTTP route, a worker resuming after a restart — holds only an id.

A lease must be a finite number of seconds greater than zero. A zero or negative one is refused rather than clamped: it would expire the instant it was granted, so the claim it should protect would be stealable by the next caller.

claim() is one call, not a read followed by a mark: the read that picks the task and the write that takes it happen inside one store lock, so two workers get different tasks or one gets None. Never the same task twice.

A claim carries an owner and an expiry, five minutes by default. When a lease lapses the task returns to todo — never to failed, because a worker dying is not the task failing, and marking it failed burns a retry that never ran. A worker may push its own lease out while it still holds it, bounded by what the run's RunBudget has left on the wall clock; there is deliberately no second timeout here to set in the place that is not enforced.

done and failed are terminal, and re-releasing one raises rather than quietly doing nothing.

An agent cannot mark its own task complete. If the model can set its own task to done, "run until the goal is met" becomes "run until it decides it is met", and a stalled run ends by declaring victory. TaskCompletionTool exists for consumers who want that, and nothing registers it: you register it on your own ToolRegistry and gate it through the ToolAuthorizer you already have. It is bound to one worker and closes only the task that worker is holding.

That rule is enforced on the source, not just in the tool: release() takes the worker, and only the worker currently holding the lease may release. Without it, a worker whose lease lapsed mid-task overwrites the claim of whoever legitimately picked the task up — the task reads done while the second worker is still working, its work is discarded, and its own release then fails as "already terminal", blaming it for the first worker's mistake. The tool checks too, because a third party's AgentTaskSource cannot be made to.

The agent must state an outcome, and it is either exactly done or exactly failed or refused with task_outcome_invalid. No case folding, no trimming, no truthiness — and an ABSENT outcome is refused too, not read as done. An implementation that resolves what it cannot read always resolves it toward the privileged answer, whether it is coercing complete into done or inferring completion from a field the agent never sent.

The list is durable state, so a task source refuses to start on a volatile store — a half-finished list that vanishes on a deploy is indistinguishable from a finished one.

No task model, no schema, no migration. AgentTask is a Protocol, so a record you already have — an ORM model, a dataclass, a row wrapper — becomes one the moment it exposes id, instruction and state. If your columns are named something else, AgentTaskMixin maps them; that is what the PHP reference spells as a trait on an Eloquent model.

Synchronous, deliberately

The store contract is sync, unlike prism-harness-ts. That port is async because Node's filesystem API is, not because the work is slow. Python's is not, the PHP reference is synchronous too, and a caller who needs this off the event loop can wrap it in asyncio.to_thread — which is what prism-workspace-py does for the same reason. Forcing async here would make every consumer of a plain WSGI application write asyncio.run around a dictionary lookup.

Drivers

  • MemorySessionStore — volatile, and says so. Its lock is a real threading.Lock but process-local.
  • FileSessionStore — durable. Atomic writes (os.replace) and a cross-process lock built on O_CREAT | O_EXCL, the one primitive atomic on every filesystem worth supporting. Two workers on one machine genuinely exclude each other; two machines over a network filesystem do not, and no file lock can promise that — use a database there.

Implement SessionStore for anything else, and declare your own durability(): only you know whether your Redis is persistent or a disposable cache, and that declaration is an assertion about your infrastructure, not a preference.

Metadata

Release files for prism-ai-harness 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for prism-ai-harness 0.1.0
File Size Uploaded
prism_ai_harness-0.1.0.tar.gz 59.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for prism-ai-harness 0.1.0
File Interpreter ABI Platform
prism_ai_harness-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 131.0 kB

Release files / prism_ai_harness-0.1.0.tar.gz

Download URL prism_ai_harness-0.1.0.tar.gz
Size 59.2 kB
Tags Source
SHA-256 checksum
How to use checksums
7bc701249cb1a6aec114cd61d053c2b54645d848a7dc60b1d777cad9cb2111ac
BLAKE2b-256 checksum
How to use checksums
3014bb7f3414cdc6b955cbe59dd1f4d4ab74a05989d5fcb4708250240d11645c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.

Transparency log

Release files / prism_ai_harness-0.1.0-py3-none-any.whl

Download URL prism_ai_harness-0.1.0-py3-none-any.whl
Size 71.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
39ba0a67d1bc125209b175b11fd2d9e067b3800e3cc325222645d831b4fa8c90
BLAKE2b-256 checksum
How to use checksums
0417dafc50830f2bcc4ae34732ed5c7cff43abeb83a7a1ebe658e871384df839
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.1

2 release files

0.2.0

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page