Agent Prompts, Report Authoring and Finalization
- internal/agent/prompt.go
- internal/agent/author.go
- systemPrompt
- firstUserMessage
- finalizePrompt
- authorPrompt
- contextManifest
- ContextPath
- AuthoredReport
- authorReport
- verifyAuthoredReport
Relevant source files · 5
Korvus talks to a model in three distinct voices, and each one has a job. The system prompt says who the agent is and what it is allowed to believe. The first user message hands over the incident and whatever the session inherited. The author prompt asks for a human-readable RCA document in the workspace, and the finalize prompt asks for the machine-readable JSON the UI actually stores. This page covers the two files that own those voices — internal/agent/prompt.go and internal/agent/author.go — and the phase between the investigation loop and finalization that turns a transcript into an artifact.
The design constraint that shapes all of it is cache warmth. The system prompt is assembled once per run and must be byte-identical across sessions of the same deployment, because every byte of variance at the front of the conversation is bytes the provider's prompt cache cannot reuse. Everything session-specific therefore goes into the first user message instead. systemPrompt takes only the skill catalog and a single boolean, docsSearch, and the documentation paragraph is a fixed constant included or omitted per deployment docsPromptSection — never something interpolated per run.
How it works
The opening turn
Agent::Run validates the request, opens the burst, and hands the session its opening user turn Run. That turn is rendered by firstUserMessage, which concatenates four things in a deliberate order: the incident's facts, the manifest of inherited context, the operator's own prompt when there is one, and the marching order.
The order matters. The incident facts come from incidentSection, which branches on store.IncidentOrigin — an alert-origin incident opens with "An Alertmanager alert fired", a manual one with "An engineer opened this incident by hand" — and then prints the id, title, severity, opener, start time, description, and sorted labels and annotations. The closing instruction comes last, because it is the only part that tells the model to act closingInstruction. Its narrowing hints are conditional on what the incident actually carries: an incident with no start time and no labels gets a bare "Investigate the root cause now.", because telling a model to filter by labels that do not exist is an instruction it can only satisfy by inventing them.
The manifest of inherited context
A follow-up session inherits artifacts from earlier sessions of the same incident — prior reports, agent output, attachments. contextManifest renders them as paths with one-line descriptions, never as contents. Injecting the prior reports themselves would put every earlier session's full output into every follow-up's transcript, most of it material the model will not need, and the transcript would grow without bound as an incident accumulated sessions. A manifest costs one line per artifact; the cost of a read the model actually wants is one tool call.
Each path comes from ContextPath, which produces context/<ordinal>-<basename>. The ordinal is the artifact's 1-based position in RunRequest.Inherited and it is what makes the path unique: store.Artifact.Path is the path in the workspace of the session that produced the file, so every session's authored report is report.md, and a follow-up inheriting two of them would otherwise describe two different documents at one path. The function is exported because it is a contract, not a rendering detail — whatever materialises context/ into the checkout must lay the files out at exactly these paths, or the manifest names files that are not there.
Two rendering details keep a single manifest entry from becoming two. Descriptions are collapsed with strings.Fields before printing, and the basename is mapped so that control characters and the two Unicode line separators become underscores the line-breaking check. An artifact with no description falls back to a phrase derived from its kind — "a report from an earlier session of this incident", "a file attached to this incident" — so an entry never ends in a bare dash that would read as a rendering bug artifactKindDescription.
Everything the manifest lists belongs to this incident; RunRequest.validate rejects a request that crosses that boundary before any of it can be described to a model.
The author phase
Between the investigation loop and finalization, the model writes the human-readable RCA document into the session's workspace. authorReport drives that bounded mini-loop and then verifies the result by reading it back off disk. The phase runs before finalize, never after: the JSON report is the guaranteed landing, the markdown is the richer artifact layered on top, and authoring first means the transcript finalize summarises already contains the document the model wrote.
Three structural properties define the phase:
- The tool set is workspace-only. workspaceToolDefs filters the session's tools by the
workspace_name prefix workspaceToolPrefix. With Prometheus and Loki still on the table, a model asked to write a report reliably goes back to querying instead, spends the budget, and writes nothing. - The template is never embedded. authorPrompt tells the model to read the template with
workspace_readand follow its structure. When no template path is configured, it names thetemplates/directory and falls back to a standard blameless postmortem structure templatesDir. The template's content lives in the workspace repository at the session's pinned commit, so changing the house format is a commit to that repo rather than a Korvus release. - The write is verified, not believed. The prompt names the destination path verbatim —
report.mdauthoredReportPath — and states that Korvus reads the file back, that a reply saying the report was written is not sufficient, and that the model has four turns maxAuthorIterations. The prompt itself is appended to the session's messages rather than persisted as a user turn the author prompt append, so the transcript a human reads does not contain text nobody wrote.
The loop is deliberately incapable of failing the run. authorReport returns no error at all: every failure degrades into Reason and the run proceeds to finalize, because an enhancement that can suppress the guarantee it enhances is the wrong shape. Three gates decide whether the phase runs at all — no workspace tools registered, a dead context, or every model provider already exhausted — and a phase that never ran is recorded through the Attempted flag rather than being reported as a failure.
Verification
verifyAuthoredReport is the acceptance criterion in code. Korvus calls workspace_read on the exact path the prompt named and only then calls the phase a success. The read is issued on a detached context with its own timeout: a session that was cancelled or timed out is exactly when "did anything get written?" matters, and running on the dead run context would report every cancelled session as having authored nothing.
Two details make the read-back trustworthy. The truncation check runs before the line-number stripper, because once the markdown is reconstructed the cut-off is invisible the truncation predicate. And the stripper drops lines without a numeric N| prefix, since those are the tool's own notices rather than document content the line-number stripper.
The result lands in AuthoredReport, which records what Korvus read back — not what the model claimed to have written. Verified answers "is there a document?"; Reason answers "did anything go wrong?", and is always filled when Verified is false but may also be filled when it is true, because a failure that did not prevent the read-back still belongs in the record. Truncated distinguishes a whole document from a prefix of one.
One last guard sits between the loop and the verification result: disownUnchangedDocument withdraws Verified from a document the phase did not produce — one already on disk from a previous revision, read back unchanged, after a loop that ended degraded. Finalize would otherwise index an earlier revision's RCA as this one's.
Finalization
finalizePrompt demands the report JSON, and its shape mirrors report.ReportData's model-facing fields — summary, key_signals, blast_radius, hypotheses, next_steps ReportData.
Its first job is honesty about how the investigation ended. Each ExitReason gets its own opening sentence: a completed investigation, a burst that ran out of budget, a cancelled or timed-out session, a context-window overflow where older tool results were pruned, an author phase that failed to write its document. Anything other than a completed run or an author failure also gets the instruction to report what was found so far, state confidence accordingly, and list what would have been checked next under next_steps — so a truncated investigation is reported as truncated rather than dressed up as a completed one.
Its second job is the reason the author phase runs first. When the author phase landed a document, the prompt becomes an index of that document rather than a second independent pass over the transcript. The markdown is pasted between --- BEGIN report.md --- markers, and the model is told that every field must describe what the document says, that where the document and its recollection differ, the document wins. The UI shows markdown and JSON side by side, so two independent derivations of the same investigation are two chances to disagree — and a summary contradicting the report underneath it is worse than either alone. Indexing makes the agreement structural rather than coincidental. If the document was truncated, an explicit note says so, because the opposite claim would be a lie the model could not detect.
finalize sends that prompt with no tools attached, up to maxFinalizeAttempts times, re-asking with the parse error when the JSON does not parse. If every provider is already exhausted, finalize is skipped entirely and the run lands a programmatic fallback report; if the session deadline has passed, finalize gets its own short grace budget, because a report from partial evidence beats no report for the operator paged at 3 a.m.
Relationships
flowchart TD
subgraph prompts["internal/agent/prompt.go"]
SP["`systemPrompt`"]
FUM["`firstUserMessage`"]
CM["`contextManifest`"]
CP["`ContextPath`"]
AP["`authorPrompt`"]
FP["`finalizePrompt`"]
end
subgraph author["internal/agent/author.go"]
AR["`authorReport`"]
VAR["`verifyAuthoredReport`"]
AOR["`AuthoredReport`"]
end
FUM --> CM
CM --> CP
AR --> AP
AR --> VAR
VAR --> AOR
FP --> AOR
RUN["Agent::Run"] --> SP
RUN --> FUM
RUN --> AR
AR --> FIN["Agent::finalize"]
FIN --> FPThe author phase depends on the workspace tools described in Workspace Sandbox Client and Built-in Tools and on the checkout semantics in Workspace Sync, Checkout and Materialization; the read-back and the persist-phase upload share the exported AuthoredReportPath constant so the three file names cannot drift. The skill catalog that fills the system prompt comes from Skill Catalog and Frontmatter Parsing, and the documentation section is gated on the deployment described in Documentation Chunking, Indexing and Search. The surrounding loop, provider fallback and tool execution live in Agent Run Loop, Tool Execution and Provider Calls, the exit-reason set in Agent Session State, Resume and Event Broadcasting, and the JSON contract in Incident Report Schema.
Reference
| Symbol | Kind | What it does | Location |
|---|---|---|---|
systemPrompt |
function | Assembles the session's system prompt from the skill catalog; byte-identical across sessions so the provider prompt cache stays warm | internal/agent/prompt.go:34 |
docsPromptSection |
constant | Fixed documentation paragraph, included only when docs_search is registered |
internal/agent/prompt.go:24 |
firstUserMessage |
function | Renders the opening user turn: incident facts, context manifest, operator prompt, marching order | internal/agent/prompt.go:76 |
incidentSection |
function | Renders the incident's facts, branching on alert vs manual origin | internal/agent/prompt.go:91 |
closingInstruction |
function | Ends the opening message with the order to investigate, plus conditional narrowing hints | internal/agent/prompt.go:132 |
contextManifest |
function | Renders inherited artifacts as paths plus one-line descriptions, never contents | internal/agent/prompt.go:160 |
artifactKindDescription |
function | Fallback one-liner for an inherited artifact with no description | internal/agent/prompt.go:188 |
ContextPath |
function | Contract for where an inherited artifact lives: context/<ordinal>-<basename> |
internal/agent/prompt.go:221 |
authorPrompt |
function | Opens the author phase: read the template, write the document, confirm in one line | internal/agent/prompt.go:367 |
finalizePrompt |
function | Demands the report JSON, discloses an abnormal end, and indexes the authored document when one exists | internal/agent/prompt.go:285 |
AuthoredReport |
struct | The author phase's outcome: what Korvus read back off disk, with Verified, Truncated, Reason, Attempted |
internal/agent/author.go:89 |
authorReport |
method | Drives the bounded authoring mini-loop, then verifies; never returns an error | internal/agent/author.go:122 |
verifyAuthoredReport |
method | Reads the document back through the workspace tools and decides whether it counts as verified | internal/agent/author.go:285 |
disownUnchangedDocument |
function | Withdraws Verified from a document this phase did not produce |
internal/agent/author.go:266 |
workspaceToolDefs |
method | The author phase's tool set: workspace tools and nothing else | internal/agent/author.go:425 |
authoredReportPath |
constant | report.md — the path the prompt names and verification reads |
internal/agent/author.go:41 |
AuthoredReportPath |
constant | Exported form of authoredReportPath, shared with the persist phase |
internal/agent/author.go:71 |
maxAuthorIterations |
constant | Four-turn ceiling on the author mini-loop | internal/agent/author.go:35 |
templatesDir |
constant | Workspace-relative directory the model lists when no template path is configured | internal/agent/author.go:46 |
workspaceReadTool |
constant | workspace_read, the tool Korvus itself calls to verify the document |
internal/agent/author.go:62 |
Generated by astrolabe from commit 0c2dca73 on branch main.