trajectory

package
v1.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func ValidateEncodable

func ValidateEncodable(v any, root string) error

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL