Skip to main content

Conversation runtime

Use this page to change how a conversation turn runs. ADR 0057 records the decisions about the ledger and the context. ADR 0068 records where the loop and the tools run. Server runtime gives the operator settings.

processResources compiles the trusted Node modules to JavaScript for the server JAR. PracticeRunnerProfile selects the review's compiled modules. The image build compiles the shared toolkit and program child. The source remains TypeScript. Stored custom practice programs can still use TypeScript.

Release qualification checks the packaged runtime with its matching agent image.

The ledger​

Practice feedback language § Conversation defines the conversation, the thread, the message and the weekly digest. Practice review glossary § Runtime terms defines the turn, the conversation sandbox and the context document.

PostgreSQL holds all Hephaestus state in three workspace-scoped tables:

TableHolds
mentor_conversationOne main conversation for each account and workspace, and one row for each thread. account_id names the owner. A main conversation can carry the Slack team, the Slack user, and the Slack channel id of the direct message.
mentor_entryEach question, answer, tool result, system entry, summary, and weekly digest. The column seq orders the entries of one conversation. surface tells whether a message was written in the app or in Slack.
mentor_turnOne question's run: status, lease, attempt, usage, and error. The partial answer is not stored.

A thread is a mentor_conversation row with parent_conversation_id and parent_entry_id. It forks the main conversation at that entry. The mentor module owns these tables, their services, and the path builder. The agent.mentor packages own TURN run admission, sandbox context, and the web API.

An entry that Pi wrote keeps the Pi session entry verbatim in payload. The server derives the kind from the Pi entry (MentorPiEntries.kind). text holds only the developer's words or Hephaestus's text. Thus, export, search, and erasure read one column.

Owner and tenancy​

The account owns the conversation. mentor_conversation.account_id is a foreign key to account with ON DELETE RESTRICT.

The server finds the conversation by account. In the app, it uses the account of the request (CurrentDeveloperLookup). In Slack, it uses the account of the Slack identity (SlackMentorIdentityResolver.resolveActiveAccountId). The first write of an account in a workspace creates its main conversation (MentorConversationService.findOrCreateMain). A lock on the account and the workspace makes sure that two first writes create one main conversation.

Each of the three tables has a unique key on (id, workspace_id). Each reference inside a workspace also names the workspace. These references are the parent of a thread, the conversation and the turn of an entry, and the conversation of a turn. feedback_copy.mentor_entry_id and feedback_copy.mentor_turn_id also name the workspace. Thus, no row can refer to a row of another workspace.

Roster sync can create an unclaimed account, and a conversation can belong to that account. When a person claims the account, MentorAccountAbsorptionContributor moves the conversations of the unclaimed account to the claiming account. If both accounts have a main conversation in a workspace, it merges them:

  1. The entries of the absorbed main conversation follow the entries of the claiming main conversation, in their own order. They get new seq values and keep their ids, so threads, turns, and feedback copies still find them.
  2. Its turns and threads move to the claiming main conversation. Before they move, a running attempt of the absorbed conversation goes back to the queue (MentorTurnService.requeueRunning), as for an expired lease. Its sandbox driver loses the lease, so the ledger refuses its later writes. The attempt is billed, and its feedback claims get UNCONFIRMED receipts (MentorLedgerEvents.AttemptTakenBack).
  3. Its summaries are erased, so the next turn summarizes the merged conversation again.
  4. Its Slack identity moves only if the claiming main conversation has none.
  5. The absorbed conversation row is deleted.

Turn lifecycle​

  1. The server writes the question entry and a QUEUED turn in one transaction (MentorTurnService.submit). The client requestId makes a repeat return the same rows. Personal data tells when the server refuses the write.
  2. The conversation sandbox driver claims its next run with POST /internal/agent/next (Turn assignment). The server claims the first queued turn of a conversation that has no running turn (MentorTurnStarter.startClaimed). The order in a conversation is the seq of the questions, which the server gives under the lock of the conversation. The clock of a server does not decide it. The turn becomes RUNNING with a lease.
  3. The server admits the turn (MentorAdmission.admit) and freezes the model and price in mentor_turn.admission. A refusal ends the turn FAILED with the refusal text.
  4. The server answers the pull with the run and epoch. The driver uses its sandbox credential to read context and run Pi. While Pi works, the driver reports the partial answer. Each call of the attempt can reach a different server.
  5. The loop ends the turn as DONE, STOPPED, or FAILED (MentorTurnCompletion.end). The end carries all Pi entries of the turn. The server that receives the end stores them in one transaction. A model call that Pi retried or recovered stays out, with the context_edit that leaves it out of the model's view (turn.ts withoutOmitted). The server bills the attempt from the proxy's row counters.
  6. In the transaction of the end, the server records the receipts that the attempt's claims are due. After the end commits, it stores them (Delivery of shown feedback).

A conversation has at most one running turn. The partial unique index ux_mentor_turn_running enforces this. The main conversation and its threads are separate conversations, so they run at the same time.

A question for a busy conversation waits as QUEUED. A conversation holds at most hephaestus.mentor.turn.max-follow-ups queued turns. The next question gets 409 with the reason FOLLOW_UPS_FULL.

A queued turn tells its clients when it waits for a free Hephaestus (MentorTurnDTO.waitingFor, MentorTurnWaits). Two facts decide the reason, and both are rows:

  • A server may start the turn. The SQL rule is the same as the claim (MentorTurnRepository.CLAIMABLE), so the two cannot disagree.
  • The turns that hold a Hephaestus slot are at least hephaestus.agent.capacity.turns. A turn holds a slot while its run is leased (MentorTurnRepository.countHeld). The run is leased while the turn runs, and while it is stopped and its loop still commits the answer.

The capacity is the number of turns that the whole installation runs at once. The Helm chart sets it from sandbox.conversationTurns on each server. The chart helper hephaestus.conversationTurns resolves the value once, so the numbers agree. Each turn has a run of kind TURN whose subject is its conversation (Agent runs). A claim takes the nonblocking turn-claim lock and counts the leased TURN runs in the installation (AgentRuns.claimSubject). It claims no turn while that count is at the capacity.

  • FREE_HEPH: a server may start the turn, and every Hephaestus slot of the deployment is taken.
  • No value: the server knows no reason. The turn starts at once, waits behind an earlier question of its conversation, or the server has no capacity.

A turn whose loop stopped holds its slot until the run reaper takes it back. Thus, for a short time, a question can show FREE_HEPH while a loop has room. It starts at the next pull.

All workspaces share the installation's Hephaestus slots. Thus, FREE_HEPH depends on turns of other workspaces. It tells a member only that every slot answers some question, never whose question it is.

A start, an end, a stop, and a requeue can each change the held slots. They can also let the next question of a conversation start. Each of these changes publishes MentorTurnWaits.SlotsChanged. After the change commits, MentorTurnWaits.publishWaits publishes a turn change for each turn that a server may start. Only these turns can wait for a free Hephaestus.

Because the publication comes after the commit, two changes that commit together each read the other. Clients read the new reason when they receive the change.

The publication covers all workspaces, newest turn first, and at most 100 turns. A turn past that bound shows its new reason at its next read. The process that commits the change publishes it, also a server without the capacity, because the reader computes the reason.

Leases and retries​

The sandbox driver renews its run lease every 25 seconds with POST lease (RENEW_EVERY_MS in driver.ts). The lease is hephaestus.agent.capacity.turn-lease-ttl, 60 seconds by default. The sandbox credential names its subject. Each run call supplies X-Agent-Run and X-Agent-Epoch.

No server holds a turn in its memory. Each turn call checks the turn's row and its run's claim (HephTurns.write). A call of an attempt that lost its run or ended gets 409 with MENTOR_LEASE_LOST. Each write of the attempt holds its run's claim until it commits (AgentRuns.requireCurrent).

The end is the only write of the turn's entries. An attempt that crashes or loses its lease stores no entries. Thus, its retry starts from the same path as the first attempt.

The server can fail to answer a context read or an end, with a network error or a 5xx. Then the loop sends the call again, after 1, 2, and 4 seconds. The end ends the turn and its run together, so the server answers a repeated end with 409 and stores nothing twice. A database trigger (mentor_turn_run_agrees) checks at each commit that a turn and its run agree.

The run reaper (AgentRunReaper, server role) takes back an expired lease through TurnRunKind:

  • A running turn on its first attempt returns to QUEUED with the same key. Its partial answer is cleared.
  • A running turn that loses its second lease ends FAILED.
  • A stopped turn whose loop did not end it stays STOPPED, without an answer.

The reaper bills the lost attempt from the proxy's record.

The database fences every model call and proposal by workspace, run and epoch. After the reaper takes back an attempt, the next call of that attempt gets 409. The driver stops when its lease expires. A sandbox driver serves one run at a time. A retry rebuilds its context from the ledger.

Stop and deadline​

A developer stops a turn with PATCH …/mentor/turns/{turnId}/status.

  • A queued turn becomes STOPPED and never runs, and its run ends. The question stays without an answer.
  • A running turn becomes STOPPED at once, but its run stays leased. The stop moves the run's deadline to 30 seconds later (TurnRunsAdapter.GRACE). Every server seals the attempt's model calls in flight. The next call of the attempt gets 409 with MENTOR_TURN_STOPPED and stop: developer. The loop then aborts Pi and ends the turn with the cut-off answer.

The next queued turn of that conversation starts after the loop ends the stopped turn, or after its run ends. An abort can leave a tool call without its result. The loop stores no result for it. In the stopped turn, Pi gives each open tool call a result when it builds a request (transformMessages). For later turns, the path builder removes the tool calls from the cut-off answer (MentorContextPathBuilder.keepCutOffAnswer).

The deadline of a turn is the LLM timeout of the conversation model binding, at most 15 minutes (AgentBindingLimits.MAX_HEPH_TURN_SECONDS). The deadline is the hard ceiling of a turn that keeps making progress. A stall ends a turn earlier: the server's LLM proxy cuts a silent model call (LlmProxyService, upstream_stalled), and the loop ends the turn as STALLED. The proxy cuts a review's streamed call on the same bounds, and the review runner retries it (Stalled model calls). Set the ceiling above the time of a few of the slowest model calls, because an answer takes a few calls. An installation runs few turns at once, so a turn as long as a review would keep every question queued behind it waiting.

The proxy puts no stream limit on a conversation call. The idle bounds and the turn's stop end the call. Its exchange waits until a minute past the ceiling, so the turn's deadline stop comes first.

The run row carries the deadline. A sandbox credential identifies the sandbox. Each call also checks the run's current claim. After the deadline, each call of the attempt except its end gets 409 with MENTOR_TURN_STOPPED and stop: deadline. A lease call still renews the lease, so that the loop can end the attempt.

The run's deadline bounds the claim: 30 seconds (TurnRunsAdapter.GRACE) after the attempt's deadline, or after a developer's stop. No lease call renews the run past it, and the run reaper then ends the turn (TurnRunKind):

  • After a developer's stop, the turn stays STOPPED.
  • After the deadline, the turn ends FAILED with "Hephaestus took too long to reply and stopped. Try again."

Only the loop has the attempt's entries, so the server's end stores none. The partial answer that readers saw last settles the feedback that it called. A lost lease has no such wait: the reaper decides about the turn.

A turn also ends after 40 model calls, retries included (MAX_TURN_MODEL_CALLS in turn.ts). A turn that keeps calling loops on a tool or on a failing call. The server logs one line for each model call and each tool call of a turn. The line names the tool, the outcome and the duration, never the words.

Each turn stages its context folder inside its conversation sandbox when its first folder tool runs.

How a turn fails​

A failed turn stores no entries. Its question stays in the path with a marker (The path), and the developer reads the text in this table.

CauseWho ends the turnText for the developer
Admission refuses the turn. The causes are no model, no AI choice, budget, unpriced usage, and a model below Hephaestus's smallest model (Limits). A requested erasure of the developer's personal data is also a cause.The server, before the loop runs the turn (MentorTurnStarter).The refusal text.
After a claim, the server cannot start the attempt, for example it cannot issue the attempt's token.The server (HephTurns.begin)."Hephaestus could not start. Try again in a moment."
The deadline passes.The loop, with the reason DEADLINE. If the loop does not end it within STOP_GRACE, the next lease call."Hephaestus took too long to reply and stopped. Try again."
The turn reaches its model call limit.The loop, with the reason ERROR and the detail call_limit."Hephaestus could not finish this reply. Try again."
The provider of a model call sends no response headers within hephaestus.mentor.turn.model-first-byte-timeout, or its stream sends no byte for model-idle-timeout, or its breaker refuses the call. The proxy answers with the code upstream_stalled or upstream_paused.The loop, with the reason STALLED."Hephaestus's AI model stopped responding, so Hephaestus could not finish this reply. Try again in a moment."
The provider breaker of the admitted model's connection is open (Server runtime).The server, before the loop runs the turn (MentorTurnStarter)."Hephaestus's AI model is not responding right now, so Hephaestus did not start this reply. Try again in a few minutes."
Pi writes no answer, or the model call fails.The loop, with the reason ERROR and a detail."Hephaestus could not finish this reply. Try again."
The conversation stays too long after compaction.The loop, with the reason ERROR and a detail."Hephaestus could not finish this reply. Try again."
The server refuses the end with 400, for example for too many entries.The loop, with the reason ERROR and a detail."Hephaestus could not finish this reply. Try again."
The server refuses the end with 413 again, after the loop replaced its documents with read notes.The loop, with the reason ERROR and a detail."Hephaestus could not finish this reply. Try again."
The second attempt loses its lease.The run reaper (TurnRunKind)."Hephaestus could not finish this reply. Try again."

"Pi writes no answer" includes a last message with no words (empty_answer). One exception applies: the turn showed words or the text of an admitted link call before that message. After them, it called only link_result, link_problem or show_feedback, and the server admitted each call. The prompt asks Hephaestus to end there without more text, so that turn ends done.

An attempt that loses its lease does not fail by itself. The loop stops it without a call to the server, and the reaper decides (see Leases and retries).

The server that receives a failed end logs it at WARN, with the reason and the loop's detail. The detail is a kind of failure from a closed set, for example model_error, context_overflow or worker_refused (FAILURE_DETAILS in heph-host-protocol.ts and in HephTurnController). It is never the text of the model provider, a tool, or the server. The server logs a detail outside the set as unrecognized.

The loop writes the same kind to its own log (failureSummary), with the server's HTTP status when the server answered. It never writes a failure's message, name, or stack, because any of them can carry the words of the server's answer.

The server logs an admission refusal at INFO. The reaper on the server role logs a lost second attempt at INFO. Metrics names the log lines.

Delivery of shown feedback​

Practice feedback language § Destinations owns the delivery rule for feedback in conversation. This section tells how a turn applies it.

Three tools put Hephaestus's words about practice feedback into the answer. Each takes an id and Hephaestus's own text. The text appears in the answer where Hephaestus calls the tool.

ToolEndpointThe server admitsEffect on delivery
link_resultPOST link-resultA result of the developer that Hephaestus may read for conversational mentoring (ResultHistoryQuery).None.
link_problemPOST link-problemA problem of such a result. The result decides for the problem.None.
show_feedbackPOST show-feedbackPrepared conversation feedback of the developer. The call claims its copy.The server settles the copy when the attempt ends.

Each endpoint answers 200 with the id, or 422 with a detail. After a 422, the loop fails the tool call, and the answer shows nothing for it. A link alone never delivers feedback.

Prepared conversation feedback is READY feedback with the destination CONVERSATION. prepared_conversation_feedback.json lists at most three pieces for the conversation of the turn. The system prompt tells Hephaestus to raise at most one piece in each turn. The code sets no limit for each turn.

The claim​

show-feedback calls FeedbackStore.claimConversationCopy while the tool call runs, in its own transaction. The claim holds the person-data write fence of the developer (Personal data). It succeeds only when all of these are true:

  • The feedback is READY, has the destination CONVERSATION, is not withdrawn, and passes the delivery policy.
  • Its only copy is a PLANNED copy of the kind CHAT_TURN.
  • The turn runs this attempt, in a conversation of the developer.

The claim sets the copy to WRITE_STARTED and records the turn in feedback_copy.mentor_turn_id. The feedback becomes UNCONFIRMED while the claim is open. Thus, an attempt that stops after the claim never leaves feedback that looks unsent. A second show_feedback call for the same feedback in the same attempt keeps the claim that the attempt holds. An attempt cannot use a claim of an earlier attempt.

The receipt​

The transaction that records how an attempt or its Slack reply ended also publishes ConversationReceiptsDue. The event publication registry keeps the event until MentorTurnCompletion.settle stored one receipt for each copy that the attempt claimed (FeedbackStore.recordConversationReceipt). A crash after the commit or a failed receipt write leaves the publication incomplete, and NotificationRedeliveryJob resubmits it. A receipt is stored once, so a repeat settles only what is left. A Hephaestus message shows a piece of feedback when it stores a show_feedback call for it whose tool result succeeded. The call names the feedback by a well-formed id in any letter case.

How the attempt endedFeedback
A turn asked in the app ended DONE, and a stored Hephaestus message showed the feedback.DELIVERED
A turn asked in Slack ended DONE.What Slack shows decides (Slack replies).
Any other end, and the attempt's entries or its last partial answer have a show_feedback call for the feedback. For example, the turn ended STOPPED or FAILED after the call.UNCONFIRMED
Any other end, and no show_feedback call for the feedback.FAILED
The attempt lost its lease (MentorTurnCompletion.billLostAttempt).UNCONFIRMED

A delivered copy records the Hephaestus message that showed the feedback in feedback_copy.mentor_entry_id. It also stores the words that the answer showed: the text of the successful show_feedback calls of that message for that feedback. The database function conversation_receipt_words holds this rule. The receipt query and the trigger that guards feedback_copy both use it. A receipt that the store refuses for good leaves the copy claimed, so the feedback stays UNCONFIRMED.

An unconfirmed or failed piece is no longer READY. Thus, no later turn or later attempt lists it or shows it again. This applies the rule that unknown visibility never permits an automatic repeat write.

Expiry and the weekly digest​

The weekly digest is a proactive DIGEST run on the conversation's subject. It uses the same sandbox runner, admission and epoch fence as a requested answer. Agent runs § Proactive digests owns its scheduling and completion contract.

ConversationFeedbackTtlSweeper (server role, every hour) expires prepared conversation feedback that is older than 14 days and whose copy has no write. The feedback becomes WITHHELD with the stop reason CONVERSATION_EXPIRED. A later show_feedback call for it gets 422.

MentorRetentionSweeper and SlackRetentionSweeper#sweepCopies (server role, every Sunday) erase entries through MentorRedaction, up to two weeks before their limit, so a missed run keeps the limit. The first erases every entry that reaches hephaestus.feedback-retention, the window that feedback and results share. The second erases each tool result of COPIES_PERSON_DATA that reaches the Slack retention, because no record says which source it copied. An erased entry erases each summary that covers it, and coverage starts at the first entry of a conversation. Thus a long conversation loses its summary after each run and pays for one compaction on its next turn. The runs are weekly so that this cost comes about once a week.

The weekly digest links each piece of conversation feedback that became DELIVERED in its week to the message in feedback_copy.mentor_entry_id. The digest creates no copy and changes no delivery. In a thread on a digest, Hephaestus discusses a piece through link_result or link_problem. A show_feedback call for a piece of the digest gets 422, because that feedback is not READY. ADR 0057 § Weekly digest owns what the digest lists.

Slack replies​

The ledger stores the complete admitted answer and enqueues one SLACK_POST action in the same transaction. SlackMentorProjector only wakes the outbox executor for a terminal turn. Its subscription is excluded from the test profile. The outbox owns the post, its lease, retry, metadata readback and terminal outcome. Slack receives no model text while a turn runs. The app's live hub keeps its existing partial-answer path.

An unknown post is read back by its metadata before its outcome is recorded. It is never posted again on an unknown outcome. Unreadable history stays uncertain. The action's end commits the Slack message reference and the receipts for practice feedback whose quotes Slack actually shows. Long answers keep the existing cut note and the full answer remains in the app.

Use Stop answer in the app to stop an unfinished turn. The Slack app subscribes to no session-stop event. The Slack note of a weekly digest uses the same adapter and readback rule, keyed by the digest.

Slack disconnect​

A Slack disconnect, an uninstall of the app, or a revoked token erases every Hephaestus entry of the workspace that was written in Slack. It also stops their running turns (SlackWorkspaceContentEraser). A disconnect takes the lifecycle lock of the Slack connection before it erases. A Slack question is stored in one transaction that takes the same lock and checks that the connection is still active for the Slack team (SlackWorkspaceResolver.whileConnected). Thus, a question that arrives during a disconnect is either not stored or erased by it.

Conversation sandbox​

Each conversation has one subject sandbox in the sandbox namespace. The image's driver.ts pulls its next run from POST /internal/agent/next and dispatches TURN to turn.ts. A run reconstructs its Pi session from the conversation ledger. ADR 0079 owns the sandbox, claim and lease rules.

The model loop and its tools run in the same container. The SandboxTemplate and its NetworkPolicy provide the isolation boundary. The server keeps provider credentials and resolves each model call through the gateway. A command in the sandbox can read the sandbox credential, so the server checks the subject, workspace, run and epoch on every proposal. A sandbox cannot use another subject's run.

Turn assignment​

TurnSandboxRunKind admits the turn in the transaction that claims its run. The sandbox can claim only its own conversation's work. The run row holds the lease and epoch in PostgreSQL. The gateway keeps no assignment map. Only the sandbox renews the lease.

A server restart can interrupt a model call, which the sandbox loop can retry through another replica. The run reaper recovers a turn whose sandbox stops renewing its lease.

Turn endpoints​

The sandbox uses its subject credential and the run and epoch headers. The root is /internal/agent/runs/{runId}. SandboxRunArgumentResolver checks the subject and the current run before the handler runs. Each request can reach any server replica.

Method and pathPurpose
GET contextThe stored entries, prompt, model, situation, system prompt and Base64 documents archive. The situation receipt and fixed documents come from the same context build.
POST leaseRenews the run's lease.
POST turn/link-resultAdmits one result for the answer. A refusal gets 422.
POST turn/link-problemAdmits one problem for the answer. A refusal gets 422.
POST turn/show-feedbackClaims prepared conversation feedback (Delivery of shown feedback). A refusal gets 422.
POST turn/searchSearches the conversation tree.
POST turn/sandbox/documentReads an item document through its source's workspace and developer checks.
POST turn/sandbox/loadPrepares a repository archive through a context loader.
POST turn/partialForwards one change of the partial answer to the live hub.
POST turn/endStores the turn's entries and outcome. It also carries the writer's last published partial snapshot.
POST endEnds or hands on the run. A completed run without a completed turn fails that turn.

A stale run gets 409. A token for another sandbox or workspace gets 401. The server takes the account's person-data write fence before it locks sandbox, run or turn rows. Proposal writes recheck the run in that transaction.

A busy erasure fence returns 503 with Retry-After. It does not mean that the run lost its lease.

The sandbox's end is the source of the last partial snapshot. The receiving replica need not have seen earlier deltas.

An end larger than the gateway's request bound gets 413. The loop sends it again with large tool results reduced to their notes (Tool results).

Context folder​

ContextFolder stages the turn's fixed documents at /workspace/work from the archive in its context response. It removes all contents of /workspace/work first. Pi session files stay outside that folder, as defined in the workspace ABI.

Pi's read and bash tools then use local files. An indexed item document is fetched on its first read and staged as a complete file before Pi opens it. Later reads use that file. No server replica caches the turn's context.

load_context asks the server to prepare an archive. The server enforces visibility and the turn's load count before it sends the archive. The sandbox checks its declared size and SHA-256, extracts it under the context folder and runs its summary command there. The loaded tree is read-only.

Loads run one at a time in the turn, so a later load of a repository replaces its previous contents. A sandbox restart loses these files. The next turn reconstructs its context from the ledger.

Documents and loads​

The context response carries a tar of the fixed documents, built with its situation receipt. HephTurnContexts formats JSON with indentation and times in the developer's time zone. The sandbox extracts that archive locally.

An item document is read on demand through its owning ContentSource.read. Examples under inputs/context/ are issue/<id>.json, merge_readiness/<id>.json, results_history/<id>.json, delivered_feedback/<id>.json and review_attempts/<kind>/<id>.json. The source checks workspace and developer access. The sandbox stages the complete document before Pi reads it and reuses that local file for later reads. A path with no source gets 404. a source can return its own NOT_FOUND result for an item that is absent or inaccessible.

Context loaders​

A context loader (integration.core.spi.ContextLoader) prepares a heavy item on request:

  • prefix() is the folder path that the loader owns, for example repos/.
  • index(workspaceId, accountId) returns the loader's section of INDEX.md.
  • load(workspaceId, accountId, path, abandoned) prepares the item and returns its directory and resolved version.

Core routes a path to a loader by its prefix only. The preparation worker takes a load permit before it acquires a capture session. The response task keeps the captured source until streaming ends and is cancelled at the turn's work deadline. A loader applies the same visibility rules as the context documents of its integration. An integration without heavy items contributes documents only.

The SCM integration implements the loader on RepositoryCheckouts (ScmContextLoader). It lists up to 40 repositories that the workspace monitors on its active connection and that no team hides. It lists each with its default branch and up to 10 open pull requests or merge requests. A load resolves the ref, fetches a missing head into the mirror, and builds a checkout as for a review. The checkout has the Git history that the resolved commit reaches. Without Git mirrors (GIT_CHECKOUT_ENABLED), INDEX.md has no repos/ section.

LimitValue
Loads in one attempt5 (TurnContextLoads.MAX_LOADS_PER_ATTEMPT), counted in mentor_turn.tools_loads
Loads at once on one worker2 (TurnContextLoads.LOADS_AT_ONCE)
Time of a loadThe time left in the turn
Size of a checkoutThe checkout bound of a review (GIT_MAX_SNAPSHOT_BYTES)

Live qualification​

scripts/sandbox-isolation.ts qualifies each SandboxTemplate's container settings and network boundary. context-folder.spec.ts checks local staging, archives, item reads and replacement of loaded repositories. The gateway integration suite checks the sandbox credential, run fence and turn routes. Run the isolation gate through the Kubernetes live suite.

The output of each tool is untrusted data from the workspace. After the turn, the results follow the rules of Tool results.

Live updates​

The server role serves GET /workspaces/{slug}/mentor/events as server-sent events (MentorLiveHub). The browser authenticates the stream with its cookie, and a native client with Authorization: Bearer.

  • On each connect, the stream sends a snapshot of the conversation.
  • Then it sends turn and entry events. Each event carries the full DTO that the server reads after the notification.
  • The end of a turn sends one entry notification with the ids of the messages that it appended. Tool results, summaries, and system entries are never shown, so the notification does not list them.
  • An erasure sends one notification for each conversation that shows a changed message.
  • A large end or erasure splits its notification, so that each payload stays below the 8000-byte limit of PostgreSQL. The app does not show tool results and summaries, so their erasure sends no notification.
  • A comment ping keeps a quiet stream open through the proxy (Server runtime).
  • A stream belongs to the account that opened it. Before each snapshot, each event, and each ping, the server checks the membership of the account (AccountMembershipQuery.isActiveVisibleMember). The account must still be an active, visible member of the workspace. If it is not, the server closes the stream without the content. Each server checks its own streams, so a removed member gets no more content on any server.

The stream has no replay log and ignores Last-Event-ID. The snapshot on each connect is the resume. After each snapshot, the client reads every loaded page of the conversation and of its threads again. An edit, a deletion, or the end of a turn does not change latestSeq, so the client cannot use it to find what changed. The client applies the events and the answers to its own writes that arrive during this read after the read completes. Thus, an older response cannot replace them.

The snapshot carries lastAnswerAt and lastReadAt. The conversation has an unread answer when lastAnswerAt is later than lastReadAt. PUT /workspaces/{slug}/mentor/conversation/read moves lastReadAt of the main conversation forward. An earlier time changes nothing, so each device of the developer can send its own time. When the marker moves, the developer's other open streams get a read event with the new lastReadAt (MentorRead).

One LISTEN connection serves each server replica. MentorLiveHub limits the streams of each account in a workspace (Server runtime). A stream whose queue of 256 events overflows gets a new snapshot.

Context of a turn​

The loop rebuilds the Pi session of a turn from rows. Nothing about a conversation stays in the loop after the turn. The request has four parts, in this order:

  1. The system prompt agent/mentor/system.md and the shared feedback writing guide. They are the same for each turn. HephTurnContexts.SYSTEM_PROMPT joins them, and GET context returns it. HephTurnControllerIntegrationTest keeps the two together at 15 KiB or less, because each model call sends them again.
  2. The tools. They are the same for each turn.
  3. The path from the server (MentorContextPathBuilder). It is the same bytes for each turn of one conversation, until it changes.
  4. The situation (MentorSituation). The loop adds it as the last message and never stores it.

The first three parts form a stable prefix for the prompt cache. The Anthropic cache breakpoint sits on the last stored message, before the situation. The Pi session id is the conversation id, so each thread has its own cache key.

The path​

The server groups entries by turn and orders the groups by their first seq. A thread's path is the main conversation up to and including its parent entry, then the thread's own entries.

The path starts at the newest summary at or before the leaf. For a thread, that is the newest summary at or before the parent. The path leaves out erased entries. An erased answer or tool result keeps a Pi skeleton, so no tool call loses its result. An erased question keeps its Pi entry id, so a summary that keeps the conversation from that question still finds its start.

The server renders each question and weekly digest as a Pi user message with a prefix, for example [Thu 2026-10-08 09:12 Europe/Berlin · Slack]. The time zone is the developer's account.time_zone, or UTC. A stopped answer ends with [stopped by the developer]. A failed turn adds [no answer: the turn failed] to its question. forTurn returns the prompt that the loop must pass to session.prompt() unchanged.

The situation​

The situation message gives these facts:

  • The time now, and whether the question was written in the app or in Slack.
  • The time since the last message.
  • For a thread, its parent and the main entries since the thread started.
  • For the main conversation, up to 10 open threads.
  • The page of the floating Hephaestus panel, if the client sent one.
  • Who the developer is: their name and their handle in each connected system (HephSituation). Context files mark the developer's own words with these handles, for example byYou on a Slack message.
  • The days of today, yesterday, this week and last week in the developer's time zone (HephSituation). Hephaestus uses these days and does not calculate dates.
  • The prepared feedback that waits in this conversation (MentorTurnEvidence.receipt): the count, and up to 3 feedback ids with their practices. It never contains the words of the feedback. It names the document that holds the feedback.
  • The line "Start with read INDEX.md: it lists what this turn can read." The situation contains no content of the work, so Hephaestus must read the folder before it answers about the work.

Context documents​

A context document is one file under inputs/context/ in the context folder. An evidence.capture.ContentSource builds each document from a MentorContextRequest: the workspace, the account, the conversation, and the question. MentorContextKeys lists the fixed keys. INDEX.md gives Hephaestus one line about each built document (ContextFolderIndex). That index is the only description of the documents. An item path, for example the detail of one issue, comes only from a resource field in a document.

The source of each document is in the module that owns its data:

Document under inputs/context/Source
user.json, workspace.json, practice_catalog.json, practice_profile.json, delivered_feedback.json with the item path delivered_feedback/<id>, prepared_conversation_feedback.json, weekly_digest.jsonagent/context/providers/mentor/
results_history.json and review_attempts.json, and the item paths results_history/<id> and review_attempts/<kind>/<id>ResultHistoryContentSource and ReviewAttemptsContentSource in agent/context/providers/mentor/
recent_authored_work.json, activity.json, merge_readiness.json, and the item paths merge_readiness/<id> and issue/<id>integration/scm/context/
slack_conversations.jsonintegration/slack/capture/ConversationCaptureSource
outline_docs.jsonintegration/outline/capture/DocumentCaptureSource

An admitted entry in delivered_feedback.json can include developerResponse.resolution from the developer's recorded response. An omitted response is unknown. It does not prove that the developer never responded. This metadata does not change the recorded review result.

Each document obeys these rules:

  • It covers the developer's own or assigned work and the developer's activity on it. Where the work needs them, it also has the names and words of other people on that work. Examples are review comments on a pull request and comments on an issue.
  • Slack messages come only from threads that the developer took part in, in monitored channels whose consent is active (ConversationThreadProjection). Other members' messages in those threads are included and marked as untrusted content.
  • It reads only repositories that the workspace monitors on its connected instance.
  • It reads no repository that a team hides from contributions.
  • It reads a related item, such as the parent of an issue, under the scope of that issue.
  • It dates each item and names its repository.
  • The loop writes each time in the developer's time zone with its weekday and offset, for example Mon 2026-10-12 14:41 +02:00 (ContextTimes). Hephaestus does not calculate a weekday.
  • Each item whose web page Hephaestus stores carries that whole url: work, reviews, comments, commits and documents. Hephaestus copies a link and never builds one. Slack messages and delivered feedback have no stored page.
  • It fits its own size limit. When it leaves something out, it says so (truncated, omittedThreads, omittedPractices, omittedForSize).
  • It keeps Hephaestus practice reviews apart from reviews by people.
  • It lists practice standings by practice group and name, never by standing.
  • It never contains the diff of a pull request. It gives the files, their line counts, and filesCoverage when file changes were not captured. Hephaestus reads the code and the diff in a repository that it loads into the context folder.

The stage​

The loop sets the stage of each partial answer from the tool call in progress. turnStage in turn.ts is the only place that holds the labels. A read or bash call that names a context document gets the label of that document, for example "Reading your practice feedback…". A call that names repos/ or runs git gets "Reading the repository…". A load_context call gets "Loading the repository…". A search_conversation call gets "Looking back through this conversation…".

At all other times the stage is "Thinking…". A tool's stage holds for the partial-answer window in which the tool started, so a fast read is shown at least once. A stage never contains a tool name, a path, a command, or a repository name, because the model selects them.

The app shows the stage of a running turn (MentorTurnPartialDTO.stage). Slack receives the complete admitted answer when the turn ends.

Tool results​

When a turn settles, the loop appends Pi context_edit entries. They replace each older read, bash and load_context result with a note: the tool, its path or command, the time, and a hint to read again. Only the latest turn's results of these tools stay verbatim. A load_context note says that each turn has a new context folder, so Hephaestus must load the repository again. A fetch_context result that a development build stored before this change is not replaced. link_result, link_problem, show_feedback, and search_conversation results stay, because they record what Hephaestus showed and found.

Compaction​

Hephaestus uses Pi's own compaction, with Pi's default summary prompt.

  • Before the prompt. Above the working trigger, the loop compacts the stored conversation before it sends the question. The trigger is 49,152 tokens, or less for a small window. The status line shows Thinking… while the loop compacts, and the summary call counts against the turn. If the conversation stays too long, the turn fails.
  • Thinking off. Every summary call runs with thinking off. A reasoning model would otherwise spend Pi's summary output limit on thinking and leave the summary unfinished. The loop sets the level on the agent state for the summary only, so the conversation stores no level change. Pi maps the level to each API's standard reasoning control and sends no provider-specific field. A model server that thinks by default keeps thinking (AI providers).
  • A failed summary. If Pi cannot write the summary, the history stays whole. The question goes out while the conversation fits the model's window, and the next turn compacts again. Thus, one failed summary never fails every later question of the conversation.
  • The trigger. mentorCompaction starts with a reserve of min(16,384, a quarter of the window). The trigger is the smaller of 49,152 tokens and the window less that reserve. Pi's reserve is then the window less the trigger. For a window of 128,000 tokens, it is 78,848 tokens. The reserve also caps the summary's output.
  • Recent messages. Pi keeps recent messages up to a target. The target is the smallest of 20,000 tokens, half the trigger and a sixth of the window. It is not a bound. Pi keeps a message whole and a tool call with its results. Thus, one long message can exceed the target.
  • Room to answer. After a summary, the loop estimates the messages and the fixed prompt. The answer needs min(output limit, an eighth of the window). If that does not fit the window, the turn fails with "still too long after shortening it".
  • A repeat. A conversation that stays past the trigger after its summary is shortened again on the next turn. Nothing guarantees that one summary is enough.
  • A kept summary. A turn that fails after its summary still stores that summary. The next turn starts from it. It summarises again only if the conversation is still past the trigger.
  • During the turn. Pi checks the trigger again before each model request. The loop cancels that compaction: the next question compacts before its prompt instead. A summary inside the turn can replace the files that the turn read. An answer then cites files that it no longer sees. A summary can also split an older turn. It then describes that turn's question as still open, next to the answer in progress. If the model runs out of room during the answer, Pi's overflow recovery still compacts the conversation.

Pi's own check before a prompt reads only the usage of its last answer. The loop also counts what the ledger holds after that answer, for example the question of a failed turn. The summary is one of the turn's entries, so the end of the turn stores it. The server stores it as a SUMMARY entry, with fromSeq and toSeq of the entries that it covers.

Erasure of an entry erases every summary that covers it. An edit of a Slack message keeps the message, but erases every summary that covers it and every search_conversation result that quotes it. A turn that ran during an erasure or an edit erases the same copies when it ends (MentorRedaction.eraseStaleCopies). The next turn then pays for one new compaction.

An erasure locks the main conversation row of each tree that it changes before it changes an entry. The context read shares that row, and the end of a turn locks it before it stores entries. Thus, a context read either omits an erased entry, or it ran before the erasure started. In the second case, the end of the turn erases each summary and search result that it stores from that read.

search_conversation finds entries with PostgreSQL full-text search (simple configuration). It searches the words that the app shows: the text of a message and the words of each link that Hephaestus showed. The server stores these words once, when it stores the message, in mentor_entry.search_text. The GIN index ix_mentor_entry_text_search covers that column. An edit replaces the words, and an erasure clears them. The personal-data export reads the same column.

It searches the main conversation, this thread, or the whole tree. It returns questions, answers, and weekly digests only, never tool results, summaries, or erased entries. It returns a bounded number of hits (Limits), each with an entry id that Hephaestus can cite.

Personal data​

Each write to the ledger first holds the person-data write fence of the conversation's owner (PersonDataWriteFence.holdForAccountWrite), before it locks a ledger row. The writes are a question, the end of a turn, a feedback claim, a vote, the edit of a Slack message, and a weekly digest. An erasure of the account waits for each write that holds the fence. After the erasure is requested, the fence refuses each later write:

  • A question, a vote, or the edit of a Slack message gets PERSON_ERASED.
  • The end of a turn stores no entries.
  • A show_feedback claim gets 422.
  • The weekly digest is not written.

The erasure of a person (MentorPersonDataCatalog) exports and deletes the person's own conversations. In the conversations of other people, it redacts the Hephaestus messages that showed feedback about the person (MentorFeedbackCopyPersonDataStore). As its last step, it redacts the tool results that can copy the person's data (MentorToolResultCopyPersonDataStore). A turn that ran during that step erases the same copies when it ends (MentorRedaction.eraseStaleCopies). A show_feedback result copies no one's data, so it stays. The data map owns the inventory of person data.

Limits​

This table gives the fixed limits that only contributors change. Server runtime § Turn limits gives the operator settings and their defaults, and Live answers the stream limits. Sandbox gateway gives the request bound of the gateway (SANDBOX_API_MAX_REQUEST_BYTES).

LimitValueWhere
Attempts per turn2MentorTurnService.MAX_ATTEMPTS
One read page, or the output that bash returns2,000 lines or 50 KBPi SDK DEFAULT_MAX_LINES and DEFAULT_MAX_BYTES, through turn.ts
Time of one bash command60 secondsturn.ts (EXEC_TIMEOUT_SECONDS)
Loads in one attempt5TurnContextLoads.MAX_LOADS_PER_ATTEMPT
Partial answer interval250 msturn.ts
Smallest conversation model32,768 tokens of context, 4,096 tokens of outputAgentBindingLimits
Entries per end500HephTurnController
Search hits20MentorEntryService.MAX_SEARCH_HITS
Words of a delivered piece in delivered_feedback.json, and in its item delivered_feedback/<id>.json600 and 2,000 characters. bodyTruncated marks a cut.DeliveredFeedbackContentSource
Prepared conversation feedback in one turn's context3 piecesPreparedConversationFeedbackContentSource
Age of prepared conversation feedback14 daysConversationFeedbackTtlSweeper.TTL_DAYS

Billing​

The loop reports no usage. The LLM proxy writes one llm_usage_event row for each model call of the turn, priced at the frozen admission rates. Each row has the turn id, the attempt number, and the run and epoch of the attempt's claim. A summary call during the turn counts against the turn's budget.