Documentation
¶
Overview ¶
Package trajectory ships Harbor's append-only planner-execution log and the fail-loudly serialise contract that closes the predecessor's silent-context-loss bug.
The contract (RFC §6.2 + §3.4):
Trajectory.Serialize() ([]byte, error)
returns canonical JSON bytes on success; on ANY non-JSON-encodable leaf, returns (nil, ErrUnserializable{Field: "..."}). No silent-drop path. The reflective walker in serialize.go tracks the field path so the error message is actionable.
ToolContext splits into a JSON-encodable Serializable map + an opaque HandleID slice. The actual non-serialisable values (callbacks, loggers, sockets) live in the runtime's HandleRegistry, which V1 implements as a process-local sync.Map. Resume with a missing handle surfaces ErrToolContextLost — never (nil, nil).
Round-trip is byte-stable: Serialize → Deserialize → Serialize returns identical bytes. The invariant is asserted in trajectory_test.go via golden bytes.
Phase 43 is the load-bearing predecessor-bug closure. The fail-loudly tests in serialize_negative_test.go + toolcontext_test.go are the gate.
Index ¶
- func ValidateEncodable(v any, root string) error
- type BackgroundMemberOutcome
- type BackgroundResult
- type ErrToolContextLost
- type ErrUnserializable
- type FailureRecord
- type HandleID
- type HandleRegistry
- type ResumeHint
- type Source
- type SteeringInjection
- type Step
- type StreamChunk
- type Summary
- type ToolContext
- type Trajectory
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ValidateEncodable ¶
ValidateEncodable reports whether v is fully JSON-encodable, failing loud with ErrUnserializable{Field: <dotted.path>} on the FIRST non-encodable leaf — never silently. It is the reusable primitive behind Trajectory.Serialize's pre-flight pass, exported so other runtime serialise contracts that share the fail-loudly invariant (the Phase 51 pause-record envelope is the first such consumer) walk the SAME walker rather than re-implementing it — re-implementing it would be the CLAUDE.md §13 two-parallel-implementations anti-pattern (RFC §3.4, D-049, D-069).
root is the field-path prefix the returned error is rooted at — pass the consumer's own envelope name (e.g. "PauseRecord") so the error path is actionable from the caller's vocabulary, not "Trajectory".
The encoding rules mirror encoding/json verbatim (see walkEncodable): chan / func / unsafe.Pointer / complex are rejected; nil interfaces / nil pointers / nil slices encode as JSON null; []byte encodes as base64; json.Marshaler implementers are probed; struct fields tagged json:"-" are skipped; cyclic graphs surface as ErrUnserializable{Field: ... <cycle>}.
Callers extract the offending path via errors.As:
var unserr trajectory.ErrUnserializable
if errors.As(err, &unserr) {
log.Printf("non-encodable leaf at %s", unserr.Field)
}
Types ¶
type BackgroundMemberOutcome ¶
type BackgroundMemberOutcome struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
// ResultRef is the ArtifactRef key for the task result, or empty
// when the task did not produce a heavy result.
ResultRef string `json:"result_ref,omitempty"`
// ErrorCode is the failure code when Status == "failed".
ErrorCode string `json:"error_code,omitempty"`
// ErrorMessage is the human-readable failure message when
// Status == "failed".
ErrorMessage string `json:"error_message,omitempty"`
}
BackgroundMemberOutcome is the planner's projection of one task's terminal record inside a BackgroundResult.
type BackgroundResult ¶
type BackgroundResult struct {
// GroupID is the resolved group's identifier.
GroupID string `json:"group_id"`
// Status is the group's terminal status ("completed" /
// "cancelled").
Status string `json:"status"`
// ResolvedAt is the resolution wall-clock timestamp.
ResolvedAt time.Time `json:"resolved_at,omitempty"`
// Members is the per-member outcome summary (Result / Error /
// Cancelled, ref-shaped per D-026).
Members []BackgroundMemberOutcome `json:"members,omitempty"`
// Reason is the cancel reason when Status == "cancelled";
// empty otherwise.
Reason string `json:"reason,omitempty"`
}
BackgroundResult is the planner's projection of a resolved non-retain-turn task group. The Runtime populates from `tasks.GroupCompletion` when the group resolves; the planner reads to integrate the outcome into the next prompt.
type ErrToolContextLost ¶
type ErrToolContextLost struct {
// Handle is the missing HandleID.
Handle HandleID
}
ErrToolContextLost is the fail-loudly sentinel returned by HandleRegistry.Get when the requested HandleID has no live mapping in the registry. Typical cause: a serialised trajectory referencing a HandleID whose owning runtime process died before resume.
The error is a struct so callers can extract the Handle via errors.As:
var lost trajectory.ErrToolContextLost
if errors.As(err, &lost) {
log.Printf("cannot resume: handle %s is lost", lost.Handle)
}
Resume MUST surface this error to the operator — silent fallback to (nil, nil) is the bug this contract closes.
func (ErrToolContextLost) Error ¶
func (e ErrToolContextLost) Error() string
Error returns the canonical message naming the missing handle.
type ErrUnserializable ¶
type ErrUnserializable struct {
// Field is the dotted path to the offending leaf, rooted at
// "Trajectory". Example: "Trajectory.Steps[2].Action.fn".
Field string
}
ErrUnserializable is the fail-loudly sentinel returned by Trajectory.Serialize when ANY leaf of the trajectory is not JSON-encodable. The Field path names the offending location (e.g. "Trajectory.Steps[3].Observation.callback") so the returned error is actionable.
The error is a struct (not a sentinel var) so callers can extract the Field path via errors.As:
var unserr trajectory.ErrUnserializable
if errors.As(err, &unserr) {
log.Printf("non-encodable leaf at %s", unserr.Field)
}
This closes the predecessor's silent-context-loss bug: there is no `try { ... } catch { return nil }`-shaped path through Serialize. Either the trajectory encodes cleanly and bytes are returned, or this struct error is raised. See RFC §3.4 + §6.2 and brief 02 §4.
func (ErrUnserializable) Error ¶
func (e ErrUnserializable) Error() string
Error returns the canonical message naming the offending field.
type FailureRecord ¶
type FailureRecord struct {
// Code is the failure classification ("schema_repair_exhausted",
// "arg_fill_failed", "graceful_failure", etc.).
Code string `json:"code"`
// Message is the human-readable failure message.
Message string `json:"message"`
// Attempts is the count of repair attempts before giving up.
Attempts int `json:"attempts"`
}
FailureRecord is Phase 44's structured-failure projection. The repair pipeline populates the fields.
type HandleID ¶
type HandleID string
HandleID is the opaque key for a non-serialisable tool-context handle (live callbacks, loggers, sockets, file descriptors). The actual value lives in the runtime's HandleRegistry; the Trajectory only carries the HandleID across pause/resume.
HandleIDs are caller-generated (ULID / UUID v4 are the recommended conventions). The registry stores values by HandleID and does not enforce uniqueness on Set — re-registering an existing HandleID overwrites silently (standard Go map semantics).
type HandleRegistry ¶
type HandleRegistry interface {
// Set installs value under id. Re-installing an existing id
// overwrites silently (standard map semantics). The runtime is
// responsible for collision-free HandleID generation (ULIDs are
// the recommended convention).
Set(id HandleID, value any)
// Get retrieves the value under id. Returns
// (nil, ErrToolContextLost{Handle: id}) on miss — never
// (nil, nil). This is the fail-loudly contract that closes the
// predecessor's silent-tool-context-loss bug.
Get(id HandleID) (any, error)
// Delete removes the mapping for id. Deleting a non-existent id
// is a no-op (returns no error).
Delete(id HandleID)
}
HandleRegistry holds the non-serialisable half of ToolContext — the live callbacks, loggers, sockets, and file descriptors the runtime carries through tool invocations. The Trajectory only stores HandleIDs; the registry stores the underlying values by ID.
V1 ships one driver: process-local, backed by sync.Map. Resume MUST run in the same Runtime process. A distributed handle directory is a post-V1 RFC concern (RFC §6.3 + RFC §12 + brief 02 §4).
Concurrent reuse contract (D-025): every method is safe to call from N goroutines on a single shared instance. The process-local driver is sync.Map-backed; concurrent_test.go is the gate.
Get on a missing HandleID returns ErrToolContextLost — never (nil, nil). This is the load-bearing fail-loudly contract.
func NewProcessLocalRegistry ¶
func NewProcessLocalRegistry() HandleRegistry
NewProcessLocalRegistry constructs the V1 process-local HandleRegistry driver. Returns a non-nil HandleRegistry ready for concurrent use.
type ResumeHint ¶
type ResumeHint struct {
// PauseToken is the opaque token the runtime issued at pause time.
PauseToken string `json:"pause_token"`
// ResumedAt is the wall-clock resume timestamp.
ResumedAt time.Time `json:"resumed_at,omitempty"`
// ResumePayload is the sanitised payload the resumer supplied
// (APPROVE/REJECT decision, USER_MESSAGE content, etc.).
ResumePayload map[string]any `json:"resume_payload,omitempty"`
}
ResumeHint signals the planner that this is a resume continuation. The unified pause/resume primitive (Phase 50) populates the hint when re-invoking the planner after a pause.
type Source ¶
type Source struct {
// Kind is the source kind: "tool" / "memory" / "skill" /
// "user_message" / "artifact".
Kind string `json:"kind"`
// Ref is the source-specific reference (tool name + step index;
// memory key; skill id; etc.).
Ref string `json:"ref"`
}
Source records a citation / provenance entry for the planner's terminal observation.
type SteeringInjection ¶
type SteeringInjection struct {
// Kind is the control event type (CtlInjectContext, CtlRedirect, etc).
Kind string `json:"kind"`
// Payload is the sanitised payload the planner sees.
Payload map[string]any `json:"payload,omitempty"`
// AtStep is the trajectory step index at which the injection
// was observed.
AtStep int `json:"at_step"`
}
SteeringInjection records a steering event the planner observed.
type Step ¶
type Step struct {
// Action is the Decision the planner returned for this step.
// Typed as `any` to avoid a cycle with the planner package;
// must be JSON-encodable (struct with JSON tags or
// map[string]any).
Action any `json:"action,omitempty"`
// ReasoningTrace is the provider-side thinking trace captured for
// this step (Phase 83e — D-147). The planner stamps it from
// `llm.CompleteResponse.Reasoning` after the step's LLM call.
// It is captured content, kept for observability and `inspect-runs`
// — it is NEVER re-injected into a subsequent prompt unless the
// agent's `ReasoningReplay` mode is `text` (D-148). Empty when the
// provider surfaced no reasoning. Reasoning content can be
// sensitive; any sink that persists or logs it routes through the
// audit redactor (CLAUDE.md §7).
ReasoningTrace string `json:"reasoning_trace,omitempty"`
// AssistantPreamble is the natural-language content the assistant
// emitted alongside the tool_call on this step — the "preamble"
// prose providers stream as `delta.content` while the structured
// `tool_calls` block is being assembled. The planner stamps it
// from `llm.CompleteResponse.Content` after the step's LLM call.
// The trajectory renderer replays it as the assistant message's
// content on the next turn so the model retains its narrative
// thread across steps.
//
// Distinct from `ReasoningTrace` which is the provider-side
// thinking channel (opt-in, often empty, sometimes redacted).
// AssistantPreamble is the model's visible prose preamble —
// always part of the wire shape, no opt-in required. Empty when
// the model emitted a tool_call with no preamble.
AssistantPreamble string `json:"assistant_preamble,omitempty"`
// Observation is the runtime's executed-decision result. Shape
// depends on the Decision: a CallTool yields a ToolResult; a
// SpawnTask yields a TaskHandle; etc.
Observation any `json:"observation,omitempty"`
// LLMObservation is the projection of Observation that becomes
// the next prompt's input. Distinct from Observation so heavy
// blobs can be summarised before reaching the LLM (D-026).
LLMObservation any `json:"llm_observation,omitempty"`
// Error captures a step-level failure (tool error, repair
// failure). Empty when the step succeeded.
Error string `json:"error,omitempty"`
// Failure is the structured failure record (Phase 44 repair
// pipeline populates).
Failure *FailureRecord `json:"failure,omitempty"`
// Streams captures per-chunk stream outputs the runtime
// collected during the step.
Streams map[string][]StreamChunk `json:"streams,omitempty"`
// StartedAt is the wall-clock step-start timestamp.
StartedAt time.Time `json:"started_at,omitempty"`
// LatencyMS is the step end-to-end latency in milliseconds.
LatencyMS int64 `json:"latency_ms,omitempty"`
// TokenEstimate is the LLM token consumption estimate for this
// step (input + output combined).
TokenEstimate int `json:"token_estimate,omitempty"`
}
Step captures one planner-step's action + observation. The Action field carries the planner's Decision shape; it is typed as `any` because the planner subpackage owns the Decision sum-type (importing it here would create a cycle). Callers serialising trajectories pass either typed Decision shapes or canonical map representations; round-trip byte stability relies on the latter (see Trajectory godoc).
type StreamChunk ¶
type StreamChunk struct {
// At is the wall-clock chunk-arrival timestamp.
At time.Time `json:"at,omitempty"`
// Data is the raw chunk payload (typically text bytes).
Data []byte `json:"data,omitempty"`
// Final is true on the terminating chunk.
Final bool `json:"final,omitempty"`
}
StreamChunk captures one chunk of a streaming tool / LLM output. The runtime streaming subsystem populates the slices during step execution.
type Summary ¶
type Summary struct {
// Goals captures the planner's running goal-tracking.
Goals []string `json:"goals,omitempty"`
// Facts captures the running fact-list extracted from prior
// observations.
Facts []string `json:"facts,omitempty"`
// Pending captures the open subgoals.
Pending []string `json:"pending,omitempty"`
// LastOutputDigest is a short hash + summary of the most recent
// observation, kept so the planner has context for the next step.
LastOutputDigest string `json:"last_output_digest,omitempty"`
// Note is the summariser's free-text note (rationale for the
// compaction, surfaced in observability).
Note string `json:"note,omitempty"`
}
Summary is the compaction artefact produced by Phase 46's summariser. Replaces the raw step history in subsequent prompt builds when the trajectory exceeds the configured budget.
type ToolContext ¶
type ToolContext struct {
// Serializable carries the JSON-encodable values shared across
// tool invocations within a run. Non-JSON-encodable values here
// cause Trajectory.Serialize to return ErrUnserializable.
Serializable map[string]any `json:"serializable,omitempty"`
// Handles carries the keys for non-serialisable values the
// HandleRegistry holds. The actual values are NEVER stored in
// this struct — they live in the runtime's HandleRegistry and
// are re-attached by key on resume.
Handles []HandleID `json:"handles,omitempty"`
}
ToolContext is the planner-facing tool-handle bundle. The split (RFC §6.3 + brief 02 §4) closes the predecessor's silent-context- loss bug:
- Serializable carries JSON-encodable values shared across tool invocations within a run (configs, IDs, plain values). Persisted across pause/resume via Trajectory.Serialize.
- Handles carries opaque HandleIDs. The actual values (callbacks, loggers, sockets) live in the runtime's process-local HandleRegistry. On resume, the runtime re-attaches each handle from the registry by ID; a missing handle surfaces ErrToolContextLost — never silently nil.
Tests for the fail-loudly contract: see toolcontext_test.go + serialize_negative_test.go.
type Trajectory ¶
type Trajectory struct {
// Query is the user-facing query that started the run.
Query string `json:"query,omitempty"`
// LLMContext is the visible-to-LLM context snapshot at run start
// (memories, system notes, prior turn summaries). Values must be
// JSON-encodable for Serialize to succeed; a non-encodable leaf
// surfaces ErrUnserializable.
LLMContext map[string]any `json:"llm_context,omitempty"`
// ToolContext is the tool-only handle bundle. The Serializable
// half is JSON-encoded as part of the trajectory; the Handles
// slice carries opaque IDs whose actual values live in the
// HandleRegistry.
ToolContext ToolContext `json:"tool_context"`
// Steps is the append-only list of trajectory steps.
Steps []Step `json:"steps,omitempty"`
// Summary is the compaction artefact produced by the trajectory
// summariser (Phase 46). Non-nil when the runtime compressed the
// trajectory; the planner sees only the compacted view.
Summary *Summary `json:"summary,omitempty"`
// Sources captures the citations / provenance for the planner's
// terminal observation.
Sources []Source `json:"sources,omitempty"`
// Artifacts is the run's named artifact references. Values are
// JSON-encoded ArtifactRefs (content-addressed; the bytes live
// elsewhere via the ArtifactStore).
Artifacts map[string]artifacts.ArtifactRef `json:"artifacts,omitempty"`
// HintState carries planner-internal hint state across steps
// (e.g. "I last summarised at step 4"). Opaque to the Runtime.
// Values must be JSON-encodable.
HintState map[string]any `json:"hint_state,omitempty"`
// SteeringInputs is the history of steering injections observed
// during the run.
SteeringInputs []SteeringInjection `json:"steering_inputs,omitempty"`
// Background captures the resolved outcomes of non-retain-turn
// background tasks the planner spawned. Keyed by TaskGroupID
// string for fast lookup.
Background map[string]BackgroundResult `json:"background,omitempty"`
// ResumeHint, when non-nil, signals the planner that this is a
// resume continuation; the planner SHOULD use the hint to
// reconstruct prior state.
ResumeHint *ResumeHint `json:"resume_hint,omitempty"`
}
Trajectory is the append-only execution log a planner sees as the run progresses. The Planner reads the trajectory (prior steps, summary, sources); the Runtime appends each step. Concurrent access between planner-reads and runtime-appends is the Runtime's responsibility — implementations of a planner-step orchestrator MUST serialise the two.
All fields carry explicit JSON tags so Serialize / Deserialize produce byte-stable output. The struct-field order is canonical: the tag set defines the on-wire shape.
func Deserialize ¶
func Deserialize(b []byte) (*Trajectory, error)
Deserialize parses canonical JSON bytes into a Trajectory. The returned *Trajectory has all `any`-valued fields decoded as the natural JSON tree (map[string]any / []any / float64 / string / bool / nil). The round-trip Serialize → Deserialize → Serialize is byte-identical for trajectories whose `any` fields were originally JSON-tree shapes.
On a malformed input (invalid JSON, type mismatch on a typed field) Deserialize returns the underlying json error wrapped — Deserialize does NOT use the fail-loudly ErrUnserializable contract (that is strictly the Serialize-side concern).
func (*Trajectory) Serialize ¶
func (t *Trajectory) Serialize() ([]byte, error)
Serialize returns the canonical JSON byte representation of the trajectory. On ANY non-JSON-encodable leaf, returns (nil, ErrUnserializable{Field: "<dotted.path>"}) — never silently.
The contract (RFC §6.2 + §3.4 + brief 02 §4):
- Success: returns canonical JSON bytes; the round-trip Serialize → Deserialize → Serialize is byte-identical for any Trajectory whose `any`-valued fields hold JSON-tree shapes (map[string]any / []any / primitives).
- Failure: returns (nil, ErrUnserializable{Field: <path>}). Field names the offending leaf (e.g. "Trajectory.Steps[3].Action.fn"). Callers extract the path via errors.As.
No silent-drop path. The predecessor's `try { json.dumps } catch { return None }` shape is rejected.
Implementation: a reflective pre-flight walker validates every leaf is JSON-encodable; on the first non-encodable leaf it returns (nil, ErrUnserializable{Field: <path>}). If the walker passes, the trajectory is marshalled via the stdlib `encoding/json` (which alphabetically orders map keys; struct fields encode in declaration order per JSON tags). The two-pass design is deliberate — the walker provides the actionable field path, which `json.Marshal`'s `*UnsupportedTypeError` does not.