Plan appended request messages

This commit is contained in:
2026-08-26 01:53:22 +00:00
parent 2e76003fd5
commit 8745d256bd
2 changed files with 870 additions and 0 deletions

View File

@@ -0,0 +1,436 @@
# Appended Request Messages Implementation Plan
## Purpose
This document is the ordered implementation plan for the target state in
[appended-request-messages.md](appended-request-messages.md). It is written for
a coding agent implementing one stage per prompt, in order. The feature
roadmap owns intent, policy, acceptance criteria, and non-goals; this document
owns sequencing and concrete implementation decisions.
The work adds one stateless, append-only request-composition primitive and
tightens prompt-definition roles to the same four-role vocabulary. It does not
add a conversation object, application validation callback, retry policy,
message limit, context estimator, or tool-call representation.
## Cross-Stage Constraints
Apply these constraints throughout every stage:
- Preserve the architecture in
[docs/policy/architecture.md](../policy/architecture.md): the root package is
the public facade, `internal/domain` owns source-neutral message invariants,
source packages own source-specific error classification, and
`internal/usecase` owns composition and execution orchestration.
- Keep `RenderedMessage.Role` a string. Do not introduce a new public message
type, enum type, builder, conversation handle, callback, or alternate run
method.
- Support exactly `developer`, `system`, `user`, and `assistant`. Normalize a
role only by trimming surrounding Unicode whitespace with
`strings.TrimSpace` and lowercasing with `strings.ToLower`; do not alias one
role to another.
- Preserve message content exactly after confirming that request-supplied
content is valid UTF-8. Empty and whitespace-only appended content is valid
at the Promptkit boundary. Do not trim, template, load, truncate, estimate,
or otherwise transform it.
- Do not add message-count, byte-size, token, context-window, or retry limits.
An upstream rejection of a structurally valid request must continue through
the existing generation-error boundary.
- Never reproduce appended role text or content in errors, formatting, or new
logging. It is acceptable to identify an invalid message by zero-based index
and property name.
- Defensively copy every retained or returned message slice and every nested
`CacheControl` pointer. Prepared execution must remain an immutable snapshot.
- Keep `PromptHash` definition-only. Treat `RenderedPromptHash` as an opaque
equality value covering the effective session and complete effective
message sequence.
- Follow [docs/policy/testing.md](../policy/testing.md): put exhaustive
invariant matrices at their narrow owner, use external-package tests for the
public contract, avoid repeating the same matrix at every layer, and keep
all tests deterministic and offline.
- Do not create a release tag, push a release, or retire either roadmap during
implementation. Release preparation follows acceptance and review of the
completed feature.
## Stage 1: Centralize Message Invariants And Tighten Prompt Roles
### Objective
Establish one source-neutral owner for chat-message role and cache-control
rules, then make prompt-definition loading and rendering publish only canonical
supported roles. This stage intentionally does not add the public request
field.
### Implementation
1. Add an `internal/domain` message-invariant file and define the untyped
string constants `RoleDeveloper`, `RoleSystem`, `RoleUser`, and
`RoleAssistant` with the exact values in the feature roadmap.
2. Add a role normalizer in `internal/domain` with the signature and behavior:
```go
func NormalizeMessageRole(role string) (string, error)
```
It must reject invalid UTF-8, trim surrounding whitespace, lowercase the
result, accept exactly the four constants, and reject blank or unsupported
values. Its errors must describe the violated role rule without embedding
the supplied value.
3. Move the source-neutral cache-control normalization currently owned by
`internal/promptdef` into `internal/domain`:
```go
func NormalizeCacheControl(control *CacheControl) (*CacheControl, error)
```
Preserve the existing contract: nil remains nil; type and TTL are trimmed;
type must be `ephemeral`; TTL must be empty or `1h`; the returned pointer is
newly allocated. Reject invalid UTF-8 before normalization and do not echo
invalid values. Keep the existing cache-control constants as the canonical
values.
4. Add domain-owned cloning helpers with these signatures:
```go
func CloneRenderedMessages(messages []RenderedMessage) []RenderedMessage
func ConcatRenderedMessages(prefix, suffix []RenderedMessage) []RenderedMessage
```
The first returns a deep clone. The second allocates one result in prefix-
then-suffix order and deep-copies both inputs. Both must clone nested
cache-control pointers and preserve role, content, order, nil cache-control,
empty content, and whitespace exactly. Do not normalize or validate inside
a clone helper.
5. Refactor prompt-definition normalization in
`internal/promptdef/filesystem_repository.go` to call the domain role and
cache-control normalizers. Convert the source-only `cacheControlFile` into a
temporary `domain.CacheControl` before normalization; do not move YAML
source types into the domain package. Store the normalized role. Preserve
the existing prompt-definition error category, selected-file context, and
message index, while ensuring diagnostics do not reproduce an invalid role
value.
6. Make `internal/prompt/go_renderer.go` defensively call the same role
normalizer before publishing each rendered message. This protects the
provider-bound invariant even for an internal repository implementation
that did not originate in `internal/promptdef`. Retain the renderer's
existing error identity and message index. Continue to render content and
session templates exactly as before.
7. Replace any literal `"assistant"` and `"user"` roles in
`internal/usecase/repairer.go` with the domain constants. Do not otherwise
change repair behavior in this stage.
### Tests
1. Add a table at the domain owner covering:
- all four accepted roles;
- surrounding whitespace and mixed-case normalization;
- blank, `tool`, `function`, and an arbitrary custom role;
- invalid UTF-8; and
- cache-control normalization, invalid values, and defensive-copy behavior.
2. Add focused prompt-definition repository coverage proving that a
mixed-case/whitespace role is published canonically and that an unsupported
role is an invalid prompt definition with its existing public-facing error
category. Do not repeat the entire domain role table.
3. Add focused renderer coverage proving defensive canonicalization and
rejection for a directly supplied internal definition. Preserve all
existing rendering and cancellation tests.
4. Run the tests for `internal/domain`, `internal/promptdef`,
`internal/prompt`, and `internal/usecase`, then run `go test ./...` before
completing the stage.
### Completion Criteria
- Every definition-supplied role reaching a rendered prompt is one of the
canonical constants.
- Prompt-definition and renderer failures retain their owning error identities.
- Cache-control rules and message deep-copy behavior have one source-neutral
implementation.
- Existing supported prompt definitions and structural repair behavior remain
unchanged.
## Stage 2: Add The Public Request Contract And Boundary Validation
### Objective
Expose the smallest public API for appended messages and convert it into a
validated, caller-independent domain request before any source or model work.
The runner may still ignore the new domain field until Stage 3.
### Implementation
1. Add `AppendedMessages []RenderedMessage` to both the public and domain
`RunRequest` values. Public GoDoc must state that values are already
rendered, are appended after all definition messages, are not templated or
file-resolved, preserve content exactly, and treat nil and empty slices as
equivalent.
2. Add a small root-package file, rather than importing `internal/domain` into
the general public types file solely for constants, and publish untyped
aliases to the domain constants:
```go
const (
RoleDeveloper = domain.RoleDeveloper
RoleSystem = domain.RoleSystem
RoleUser = domain.RoleUser
RoleAssistant = domain.RoleAssistant
)
```
Give every exported declaration exact GoDoc. Do not introduce a named role
type; assignments and comparisons with ordinary strings must remain
frictionless.
3. Generalize `RenderedMessage` GoDoc so it describes any prepared,
provider-bound text chat message rather than only a definition-supplied
message. Document the four supported roles and refer callers to the
constants.
4. Add one conversion helper in `convert.go` that processes appended messages
in order. For each message it must:
- reject invalid UTF-8 in content without reproducing it;
- normalize and validate the role through
`domain.NormalizeMessageRole`;
- normalize and defensively copy cache control through
`domain.NormalizeCacheControl`; and
- return a newly allocated domain slice with independently allocated nested
cache-control pointers.
Wrap failures with the zero-based appended-message index and property name,
but not the failing value.
5. Call that helper from `toDomainRunRequest` and store the result on the
domain request. Because `Prepare`, `PrepareExecution`, and `Run` already use
this shared conversion boundary, conversion failures must emerge as the
existing public `ErrInvalidRequest` before source resolution, preparation,
capacity admission, or model generation.
6. Extend `RunRequest.String` and `RunRequest.GoString` through their shared
redacted formatter to include only `AppendedMessages:<count>`. Do not emit
roles, content, cache-control values, or a serialized message value.
### Tests
1. Add an external-package public contract test for the exact constant values
and for ordinary string compatibility.
2. Put the request-message validation matrix at the public conversion owner.
Cover supported normalization, invalid UTF-8 in role and content,
unsupported roles, invalid cache control, empty content, whitespace-only
content, and message-indexed errors that omit the original role and content.
3. Add a small parity test proving that `Prepare`, `PrepareExecution`, and
`Run` all classify an invalid appended message as `ErrInvalidRequest` and do
not invoke configured source/model fakes. Do not repeat the complete
invalid-value matrix through all three operations.
4. Extend the existing request-formatting test so both `String` and `GoString`
expose the count and cannot contain distinctive role/content values.
5. Run root-package tests and `go test ./...` before completing the stage.
### Completion Criteria
- The only new public surface is one `RunRequest` field and four untyped string
constants.
- Every public operation rejects malformed appended messages consistently and
before side effects.
- Request formatting remains redacted.
- Public-to-domain conversion owns an independent, canonical snapshot of the
request values.
## Stage 3: Compose, Freeze, Hash, Execute, And Repair The Effective Prompt
### Objective
Make appended messages part of the effective rendered prompt everywhere after
ordinary rendering, including immutable preparation, client execution,
structural repair, and opaque rendered-prompt identity.
### Implementation
1. In the shared completion phase in `internal/usecase/runner.go`, append the
already-normalized domain request messages immediately after ordinary
rendering and application of the direct-session override. Use the domain
concatenation helper so the resulting `RenderedPrompt.Messages` owns a deep
copy of both the ordinary prefix and appended suffix.
2. Use that combined rendered prompt for all subsequent work: rendered-prompt
hashing, `PreparedRun.Messages`, `PreparedExecution` details, the initial
generation request, `RunResult.RenderedPromptHash`, and the original-message
input to structural repair. Do not alter `PromptHash` or input hashes.
3. Refactor `clonePreparedRun` and any other prepared-message clone path to use
the shared domain deep-clone helper. Keep public conversion helpers as the
public/domain boundary, but remove duplicate internal cache-control pointer
cloning where the new domain helper is the appropriate owner.
4. Ensure `PreparedExecution` freezes the combined messages. The opaque
retained value, every call to `Details`, and a later `RunPrepared` must have
independent message slices and cache-control pointers. Caller mutation of
the original request or of returned details must not alter execution.
5. In the default structural repairer, start every repair request with a deep
copy of the complete combined original sequence. Append only the latest
nonempty candidate with `domain.RoleAssistant`, followed by the corrective
message with `domain.RoleUser`. Preserve the existing empty-candidate rule,
diagnostic bound, retry budget, usage accounting, capacity path,
cancellation, target, session, credential, and structured-output behavior.
6. Replace the delimiter-based `hashRenderedPrompt` encoding with a versioned,
length-framed binary encoding. Use this exact field order:
1. the fixed bytes `promptkit/rendered-prompt/v2\x00`;
2. the effective session ID as a `uint64` big-endian byte length followed by
its bytes;
3. the message count as `uint64` big-endian;
4. for each message in order, role and content as separately length-prefixed
byte strings;
5. one cache-control presence byte; and
6. when present, type and TTL as separately length-prefixed byte strings.
Hash the resulting framing with SHA-256 and retain the lowercase hexadecimal
public representation. The terminating NUL is part of the fixed marker and
must be written before the first length. Use a fixed eight-byte buffer with
`binary.BigEndian.PutUint64`; do not use `binary.Write`, JSON, `fmt`,
separators, or an input-sized staging buffer. The returned value is opaque,
so do not preserve the old digest. This change deliberately prevents caller
content from colliding with structural separators and streams in constant
auxiliary space.
7. Confirm that the built-in OpenAI-compatible client forwards the canonical
role and exact content without provider-specific translation. No new
provider role branching or proactive context validation is permitted.
### Tests
1. At the runner owner, prove that ordinary rendered messages are an exact
prefix, appended messages retain their order and content, and nil/empty
suffixes retain current behavior.
2. Prove that `Prepare`, `PrepareExecution`, and `Run` agree on the complete
sequence and rendered-prompt hash, and that the injected model client sees
exactly that sequence.
3. Prove immutable ownership by mutating the original appended slice and its
cache-control pointer after `PrepareExecution`, then mutating a value from
`Details`; neither mutation may affect later details or `RunPrepared`.
4. Extend hash tests to establish:
- normalized-equivalent roles yield the same rendered hash;
- changing role, content, order, cache-control presence/value, or session
changes the rendered hash;
- appended-message changes do not change `PromptHash`; and
- the one-message sequence `{role: "user", content:
"x\n---\nassistant\ny"}` hashes differently from the two-message sequence
`{user, "x"}, {assistant, "y"}`, even though the legacy separator
encoding could not distinguish them.
Test observable relationships, not a golden SHA-256 digest.
5. Extend repair tests to prove the exact order: ordinary prefix, consumer
suffix, latest structurally invalid assistant candidate when nonempty, and
Promptkit corrective user message. Also prove that a later repair begins
again from the complete original combined sequence rather than accumulating
prior repair turns.
6. Add one focused OpenAI-compatible request test covering canonical role
forwarding, including `developer`, and exact content preservation. Do not
duplicate all HTTP client or role-validation tests.
7. Exercise one otherwise valid combined request that the fake provider rejects
and confirm the existing `GenerationError` status/provider-detail contract
remains intact. Reuse the existing generation-error test scaffolding rather
than creating a second status matrix.
8. Run tests for `internal/domain`, `internal/usecase`, `internal/llm`, and the
root package, then run `go test ./...` and `go test -race ./...` before
completing the stage.
### Completion Criteria
- Every prepared, inspected-prepared, executed, and repaired message sequence
uses the same ordered combined prompt.
- Prepared execution remains immutable across every caller-visible ownership
boundary.
- Rendered-prompt hashing is unambiguous, streaming, and sensitive to every
contracted field while `PromptHash` remains definition-only.
- Existing repair, usage, capacity, cancellation, credential, validation, and
provider-error behavior is preserved.
## Stage 4: Publish Canonical Documentation And Complete Validation
### Objective
Bring every canonical documentation owner into line with the implemented API,
record the intentional prompt-format compatibility change, and run the full
repository validation workflow.
### Documentation
1. Finish the exact root-package GoDoc for `RunRequest.AppendedMessages`, the
four role constants, `RenderedMessage`, `PreparedRun.Messages`, and the
relevant `Engine` methods. Document normalization, ordering, ownership,
empty-content behavior, error identity, and statelessness at the narrowest
declaration that owns each rule.
2. Update [docs/formats.md](../formats.md) as the canonical prompt-file
contract:
- enumerate the four accepted message roles;
- state trim-and-lowercase normalization;
- state that blank, custom, `tool`, and `function` roles are invalid; and
- identify this as an intentional tightening from the prior nonblank-string
rule for consumers migrating to the next minor release.
Do not document the Go-only `AppendedMessages` field as a file-format field.
3. Add one concise, application-neutral example to
[docs/consumers/pkg-promptkit.md](../consumers/pkg-promptkit.md) showing a
previous assistant response and a corrective user message passed through
`AppendedMessages`. Explain that the messages are already rendered and may
contain sensitive data, Promptkit remains stateless, each call re-resolves
current sources, the consumer owns any semantic retry budget, and
`PrepareExecution` plus opaque hash comparison is available when a
pre-execution equality check is required.
4. Update [docs/internal/sources.md](../internal/sources.md) with shared role
normalization and source-owned failure behavior. Update
[docs/internal/runner.md](../internal/runner.md) with append ordering,
immutable preparation, the versioned framed hash, and structural-repair
interaction. Update [docs/internal/llm.md](../internal/llm.md) only if needed
to clarify that the client receives canonical text messages and leaves
supported-role/context rejection to the provider.
5. Update the
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
to list the four provider-bound roles, state that Promptkit sends them
without translation, and explain that a backend/model rejection follows the
normal provider-error path. Keep tool/function payloads outside the
supported wire contract.
6. Update [docs/policy/architecture.md](../policy/architecture.md) only if the
implementation created a durable architectural invariant not already
covered by domain ownership, the public facade, and the
application-neutral boundary. Do not add feature-level API details to
policy. No new internal package is expected, so do not change the internal
package overview merely because a file was added.
7. Do not create a release tag in this stage. Record in the final handoff that
the next release must be a minor version and that its release note must
summarize both the new append API and migration from nonstandard
prompt-definition roles, linking to GoDoc and `docs/formats.md` for detail.
Create the release document and README link only as part of the repository's
subsequent release-preparation task.
### Final Verification
1. Review new tests against [docs/policy/testing.md](../policy/testing.md).
Consolidate repeated setup and assertions only where it clarifies behavior;
do not replace behavioral tests with helper-implementation tests.
2. Run the complete maintainer validation workflow from
[docs/development.md](../development.md), including:
- all ordinary and race-enabled Go tests;
- `go vet` and the build;
- both maintained examples;
- Go and Markdown formatting/link checks;
- diff, ignored-file, workspace, vendoring, module-replacement, and
credential hygiene checks.
3. Review the final diff to confirm that no provider-specific Notarius concept,
conversation state, message limit, content logging, new credential handling,
or unrelated refactor entered the change.
4. Leave both roadmap documents in place for post-implementation review. Their
retirement belongs to a later cleanup after the feature is accepted.
### Completion Criteria
- Canonical GoDoc, format, consumer, internal, and integration documentation
agree with the implemented behavior and do not duplicate ownership.
- Migration impact for unsupported legacy prompt roles is explicit.
- The complete maintainer workflow passes from a clean, supported Go setup.
- The repository is ready for an implementation review followed by minor
release preparation.
## Open Questions
None. The feature roadmap and this plan resolve the public API shape, role
vocabulary, normalization, content handling, ownership, composition order,
hash framing, repair interaction, provider boundary, documentation ownership,
and release compatibility policy required for implementation.