runctx

package
v1.25.0 Latest Latest
Warning

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

Go to latest
Published: Aug 1, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Input-artifact disposition observability.

The disposition resolver and the materializer are pure — they RETURN the winning precedence layer and any degradation fact as typed values; the caller logs and emits. This file is the canonical event vocabulary the shared run-loop helper ResolveInputArtifacts emits on behalf of every caller (cmd / devstack / a headless consumer that supplies an emit closure), so operators can see which disposition fired, why (which layer won), and that the runtime never silently overrode them.

newruncontext.go — the shared production RunContext factory.

A headless embedder that wants to run a single goal after assembling a stack previously had to hand-build a `planner.RunContext` field by field, re-deriving the memory / skills / artifact / streaming projections the run-loop drivers already compose. NewRunContext is the ONE factory that turns stack-derived subsystem handles into a fully-projected RunContext, composing the SAME projection helpers the drivers use (FetchMemoryBlocks, ProjectSkillsDirectory, ResolveInputArtifacts, the bus chunk publisher) — never a second hand-rolled construction site. The one-call Stack.RunOnce runner is its first consumer; an embedder building its own RunSpec composes it directly.

Import direction: this file may import `tools` / `llm` (the projection + streaming surfaces) in addition to the package's existing `planner` / `memory` / `skills` / `artifacts` / `events` imports — none of those import this package, so no cycle. The factory stays out of `steering`: it produces the RunContext (RunSpec.Base); the caller wraps it in a RunSpec with the planner / executor / max-steps it owns.

Package runctx exports the RunContext-population helpers a run-loop driver applies between "task spawned" and "planner.Next" — the code that turns subsystem state (memory, skills, artifacts, the terminal Finish) into what the planner actually sees.

The settled contract is that the planner never imports runtime internals: everything it can see arrives through `planner.RunContext`. The projection code that POPULATES RunContext is therefore the runtime's half of the planner contract. Previously that half lived as unexported `package main` helpers in `cmd/harbor`, hand-duplicated in `harbortest/devstack` (the mirror tax — the SDK friction audit found a THIRD drifting copy of the keyword shaper). This package is the promotion that makes the contract real for every caller: `cmd/harbor`, the devstack test kit, and a headless SDK consumer building its own RunSpec all compose the SAME projections.

Import direction (recorded in the phase plan): `internal/runtime/*` may import `planner` / `memory` / `skills` / `artifacts`; `internal/planner` gains NO new imports — in particular no `memory` import.

All five helpers are pure functions (or functions over their explicit dependencies) with no package-level mutable state — they are trivially safe for concurrent use.

executed the deprecation notice: the `ExtractSkillKeywords` query shaper is DELETED along with the raw-Search `<skills_context>` injection path it served; the `skills.Directory` view (projected via ProjectSkillsDirectory) is the canonical producer.

Index

Constants

View Source
const CallerSuppliedKey = "caller_supplied"

CallerSuppliedKey is the fixed, runtime-owned `External` tier map key under which caller-supplied memory is composed. Runtime producers MUST NOT write it, and no caller input can name it.

View Source
const EventTypeInputDispositionResolved events.EventType = "task.input_disposition.resolved"

EventTypeInputDispositionResolved is published once per input artifact when the run loop resolves the attachment's disposition. The payload carries the resolved disposition, the precedence layer that won (caller_hint / agent_policy / runtime_default), and — when the resolved disposition could not be honoured — the typed degradation fact (e.g. an unknown `tool:<name>`, or `inline` for a non-image MIME). A resolved `provider_native` is honoured as-is; the provider upload itself is observable via the `llm.provider_file.uploaded` event.

View Source
const ExternalTierName = "read_only_external_memory"

ExternalTierName is the observability label for the `External` memory tier — the name a run-loop driver reports on the admission event so a Console reader can tell WHICH tier caller-supplied content reached without inspecting the prompt.

It matches the ReAct planner's wrapper tag for that tier. It is a LABEL, not the renderer's source of truth: the planner owns how it renders its own wrappers, and nothing here feeds prompt assembly.

Variables

View Source
var ErrCallerMemoryInvalid = errors.New("runctx: caller-supplied memory is not admissible")

ErrCallerMemoryInvalid is returned by ComposeCallerMemory for a payload it will not admit: an explicit JSON `null`, or bytes that are not a valid JSON document. It is a sentinel so a run-loop driver can classify the failure without string matching.

The Protocol edge already refuses both shapes before a task exists; this check is the second, in-process gate for a headless caller that constructs a run without passing through the Protocol. Neither gate degrades silently (CLAUDE.md §13).

View Source
var ErrCallerMemoryTierShape = errors.New("runctx: External memory tier is not a map and cannot be composed into")

ErrCallerMemoryTierShape is returned when the supplied MemoryBlocks carries an `External` tier that is not a `map[string]any` and therefore cannot be composed into at key granularity.

This is a fail-loud on a broken runtime invariant, not a caller error: every runtime producer of the External tier writes a map. Overwriting a non-map tier would silently destroy whatever the other producer put there, which is precisely the two-producers-on-one-slot failure this design exists to avoid.

Functions

func ComposeCallerMemory added in v1.25.0

func ComposeCallerMemory(mb *planner.MemoryBlocks, raw json.RawMessage) (*planner.MemoryBlocks, error)

ComposeCallerMemory composes caller-supplied content into the run's `External` memory tier under CallerSuppliedKey.

It returns a NEW *planner.MemoryBlocks and never mutates its argument: the run loop's value is derived from identity-scoped store reads and must stay untouched.

The no-aliasing guarantee is scoped to the tier this function WRITES, and — stated precisely, because an earlier wording overclaimed it — scoped to that tier's MAP rather than to the values inside it. `External` is rebuilt as a fresh map, so adding, replacing or deleting a TOP-LEVEL key on either side cannot be observed through the other; in particular the caller's key is never written into the input. The entries are copied SHALLOWLY, so a value that is itself a map or slice is SHARED, and a mutation reaching INTO such a value — the runtime's own producer writes `{"recalled_turns": []map[string]any{…}}`, so this is the normal shape rather than a corner — IS visible on both sides.

That sharing is safe today for a structural reason, not a hopeful one: the input has exactly one holder. The blocks are built per run by the memory fetch and handed straight to this function, whose result replaces the local; the pre-composition value is then unreachable, so there is no second observer for a nested write to reach. Deep-copying an arbitrary `any` to close the gap would need a reflective or round-trip copy — the same cost, and the same objection, that keeps `Conversation` shared below. Both halves are pinned by a test, so a producer that starts retaining its blocks past composition turns that test's premise red rather than silently invalidating this paragraph.

`Conversation` is carried across BY REFERENCE — it is an `any` this function never reads into and never writes, so copying it would mean a reflective or round-trip deep copy of an arbitrary value, and a partial (top-level-only) copy would be worse than none: a half-true invariant is what a future author reasons from. The sharing is safe today because nothing downstream writes the tier — the planner's renderer only reads it — and it is pinned by a test asserting BOTH halves, the copy AND the share, so a change to either forces this sentence to be re-derived rather than silently invalidated.

Behaviour:

  • An empty raw returns mb unchanged and byte-identically (the same pointer), so a run that supplied no caller memory is indistinguish- able from one made before the field existed.
  • A nil mb with a non-empty raw allocates a MemoryBlocks carrying only the External tier — composition must also work for a session with no stored memory at all, and for a runtime with no memory subsystem configured.
  • An explicit JSON `null`, or invalid JSON, returns ErrCallerMemoryInvalid. It is never treated as "absent".
  • `Conversation` is copied across untouched. It is a claim about the session's stored turns that only the runtime may make, and it is written unconditionally by ProjectMemoryBlocks — a second writer would be silent last-writer-wins.

func DispositionHints added in v1.4.0

func DispositionHints(raw map[string]string) map[string]planner.AttachmentDisposition

DispositionHints converts the string-typed per-attachment hint map a task record carries (`tasks.Task.InputArtifactDispositions` — the Protocol carrier, validated at the edge via `planner.ParseDisposition`) into the typed hint map InputArtifactOptions.Hints expects. Nil in, nil out.

func ExtractAssistantAnswer

func ExtractAssistantAnswer(fin planner.Finish) string

ExtractAssistantAnswer pulls the planner's natural-language answer out of a terminal Finish for the memory.AddTurn writeback and the `planner.AnswerEnvelope` projection (promoted by the runtime promotion). The react planner's FinishGoal carries Payload = map[string]any{"answer": "<the LLM's answer>"}. Other planners may shape Payload differently; we accept any string-valued "answer" key and otherwise fall back to a Sprintf so something always lands in memory (matching CLAUDE.md §5 fail-loud — silent "nothing written" would lose the run's outcome).

func FetchMemoryBlocks added in v1.4.0

func FetchMemoryBlocks(
	ctx context.Context,
	store memory.MemoryStore,
	id identity.Quadruple,
	query string,
	recall memory.RecallSettings,
	logger *slog.Logger,
) (*planner.MemoryBlocks, error)

FetchMemoryBlocks fetches the session's memory patch, optionally recalls semantically similar past turns for the given query, and projects both into the planner's MemoryBlocks. This is the single production home for the memory step of run-loop RunContext population.

When recall is disabled (RecallSettings.Enabled == false) the output is identical to calling GetLLMContext + ProjectMemoryBlocks directly — the External tier remains nil and SearchTurns is never called.

When recall is enabled:

  • SearchTurns is called with the session-scoped identity triple (RunID zeroed) and the caller-supplied query.
  • Turns scoring below RecallSettings.MinScore are dropped.
  • Turns whose UserMessage is already present in the patch's RecentTurns window are deduped out.
  • Each remaining turn's user and assistant text are capped at recalledTurnTextCap bytes (with a "…[truncated]" marker).
  • The resulting slice is marshalled as map["recalled_turns"][]map[string]any and stored in MemoryBlocks.External alongside the Conversation tier.

An error from GetLLMContext or SearchTurns is returned unwrapped so the caller can fail the run loudly via MarkFailed(runtime_fetch_error). There is no silent fallback to rolling-summary-only on a recall error.

A Debug line is emitted on the supplied logger when recall fires (count + top score) so operators can see retrieval activity without seeing turn content. A nil logger falls back to slog.Default().

func FinishAnswerEnvelope added in v1.10.0

func FinishAnswerEnvelope(fin planner.Finish, traj *planner.Trajectory, schema *planner.OutputSchemaValidator) (planner.AnswerEnvelope, error)

FinishAnswerEnvelope builds the canonical planner.AnswerEnvelope from a terminal Finish, applying output-schema validation at the ONE shared site. The three call sites (assemble.RunOnce, the cmd/harbor per-task driver, the devstack twin) consume it so the answer envelope — and, on a schema-constrained goal Finish, the validated AnswerPayload — is byte-identical wherever a run is hosted.

Behaviour:

  • schema == nil, OR fin.Reason != FinishGoal → the plain three-key envelope ({answer, finish_reason, tool_calls_seen}), byte-identical to a no-schema run. A non-goal terminal (a steering CANCEL surfaced as Finish{Cancelled}, a NoPath / deadline / constraints terminal) was never asked to produce a schema-shaped answer, so it is NEVER validated and NEVER returns ErrOutputInvalid.
  • schema != nil AND fin.Reason == FinishGoal → the terminal payload is captured to its exact bytes at the validation site (never a lossy string round-trip) and validated against the compiled schema. On success AnswerPayload carries the exact validated bytes and Answer carries their string rendering. On a capture or validation failure the returned error wraps planner.ErrOutputInvalid — a loud, typed failure, never a silent fallback to unvalidated text (CLAUDE.md §13). A caller mapping this to a task terminal stamps planner.TaskErrorCodeOutputInvalid.

The trajectory is read only for planner.CountToolInvocations; a nil trajectory yields ToolCallsSeen == 0.

func NewRunContext added in v1.8.0

func NewRunContext(
	ctx context.Context,
	src Sources,
	q identity.Quadruple,
	goal string,
	opts ...Option,
) (planner.RunContext, error)

NewRunContext projects the stack-derived sources into a fully-formed planner.RunContext for the run identified by q and driven toward goal. It is the single production home for run-loop RunContext population that a one-call runner or a headless RunSpec builder composes; the per-task drivers keep their own population bodies (they thread control-plane projections this factory deliberately omits) but call the SAME underlying helpers, so the projected memory / skills / input-artifact / streaming surface is identical. The read-only cross-turn session-artifact manifest the dev drivers attach is one of those control-plane projections this factory omits; a headless run that needs input artifacts resolves them explicitly via WithInputArtifacts.

Identity is mandatory and fails loud: an incomplete quadruple returns a wrapped identity error rather than a half-scoped RunContext. Memory and skills are SESSION-scoped (the fetch quadruple zeroes RunID) so a run inherits the session's accumulated state; a fetch error is returned unwrapped enough for the caller to fail the run loudly (no silent degradation, CLAUDE.md §5/§13).

func ProjectMemoryBlocks

func ProjectMemoryBlocks(patch memory.LLMContextPatch) *planner.MemoryBlocks

ProjectMemoryBlocks shapes a memory.LLMContextPatch into the JSON-encodable map the planner's `<read_only_conversation_memory>` wrapper renders. Returns nil when the patch is empty — the wrapper is omitted entirely. Only the Conversation tier is populated here; callers that enable semantic recall (via FetchMemoryBlocks) will additionally populate the External tier with retrieved turns.

func ProjectSkillsContext

func ProjectSkillsContext(ranked []skills.RankedSkill) []any

ProjectSkillsContext shapes a []skills.RankedSkill into the []any the planner's `<skills_context>` wrapper renders ( the reasoning-replay knob). Each element is a small map carrying the body fields the LLM consumes (name / title / description / steps). An empty input returns nil so the wrapper is omitted.

func ProjectSkillsDirectory

func ProjectSkillsDirectory(views []skills.SkillView) []any

ProjectSkillsDirectory shapes a `skills.Directory.View` snapshot into the []any the planner's `<skills_context>` wrapper renders (, executing the deprecation notice: the keyword-shaped raw-Search injection path and its `ExtractSkillKeywords` helper are deleted; the Directory's bounded, pinned-then-recent, capability-filtered, redacted browse window is the canonical producer). Each element is the compact `skills.SkillView` projection (name / title / trigger / task_type / pinned) — full skill content stays behind the `skill_get` meta-tool. An empty input returns nil so the wrapper is omitted.

func ResolveInputArtifacts

func ResolveInputArtifacts(
	ctx context.Context,
	store artifacts.ArtifactStore,
	q identity.Quadruple,
	ids []string,
	logger *slog.Logger,
	opts InputArtifactOptions,
) []planner.InputArtifactView

ResolveInputArtifacts pre-fetches the metadata (+ bytes for inline dispositions) for every operator-uploaded artifact ID on a task, producing the `planner.InputArtifactView` slice the run loop hands to the planner's first turn (promoted by the runtime promotion). The synchronous pre-resolution keeps the planner's prompt assembly I/O-free.

each view's `Disposition` is resolved here via the pure `planner.ResolveDisposition` (per-attachment caller hint > per-agent policy map > runtime default) + capability normalisation via `planner.EffectiveDisposition`. This helper is a THIN caller — the precedence logic lives in `internal/planner`; the helper adds only the I/O (byte fetch for inline) and the logging/emission of the returned winning-layer + degradation facts (the resolver is pure and never logs).

Failures are bounded:

  • Nil artifact store with non-empty IDs → empty slice + Warn log (the LLM still sees a text-only prompt; the operator can re- attach after wiring the store). Avoids a hard fail-loud here because the artifact-store dependency is genuinely optional in some dev postures.
  • `GetRef` not-found / errored → skip that ID + Warn (the rest of the slice survives; the artifact may have been GC'd between spawn and run).
  • `Get` (bytes fetch) errored on an inline disposition → keep the entry but leave Bytes nil. The materializer falls back to a stub-text part for missing-bytes inlines.

The scope on every store call is the run's identity tuple — the artifact store enforces tenant isolation on read. A nil logger defaults to slog.Default() so the Warn paths above stay loud for every caller.

Types

type InputArtifactOptions added in v1.4.0

type InputArtifactOptions struct {
	// Hints maps an artifact ID to its per-attachment caller hint —
	// the top precedence layer. The dev run loop populates it from
	// `tasks.Task.InputArtifactDispositions` (the Protocol
	// `disposition` carrier); a headless caller constructs it
	// directly (or skips this helper entirely and sets
	// `InputArtifactView.Disposition` on hand-built views).
	Hints map[string]planner.AttachmentDisposition
	// Policy is the per-agent disposition policy map — the middle
	// precedence layer (`harbor.yaml` `multimodal.disposition`, or a
	// programmatic `planner.DispositionPolicy`).
	Policy planner.DispositionPolicy
	// Catalog, when non-nil, validates `tool:<name>` dispositions
	// against the run's visible tool set: an unknown name degrades
	// to `ref` with a logged + emitted degradation fact.
	Catalog planner.ToolCatalogView
	// Emit, when non-nil, publishes one
	// [EventTypeInputDispositionResolved] event per resolved
	// artifact (the dev run loop passes its identity-stamping
	// emitter). Nil skips emission; the slog records still land.
	Emit func(events.Event)
}

InputArtifactOptions carries the disposition inputs to ResolveInputArtifacts. The zero value reproduces the pre-84b behaviour exactly: no hints, no policy (the runtime default resolves `image/*` → inline, everything else → ref), no catalog validation of `tool:<name>` values, and no event emission.

type InputDispositionResolvedPayload added in v1.4.0

type InputDispositionResolvedPayload struct {
	events.SafeSealed `json:"-"`

	// Identity is the run's identity quadruple.
	Identity identity.Quadruple `json:"identity"`
	// TaskID is the task whose input artifact was resolved.
	TaskID string `json:"task_id"`
	// ArtifactID is the input artifact's content-addressed id.
	ArtifactID string `json:"artifact_id"`
	// MIME is the artifact's IANA media type.
	MIME string `json:"mime"`
	// Disposition is the EFFECTIVE disposition the materializer will
	// honour (`ref` / `inline` / `provider_native` / `tool:<name>`).
	Disposition string `json:"disposition"`
	// Layer is the precedence layer that won the resolution
	// (`caller_hint` / `agent_policy` / `runtime_default`).
	Layer string `json:"layer"`
	// Degraded is true when the resolved disposition could not be
	// honoured and fell back to `ref`.
	Degraded bool `json:"degraded"`
	// DegradedFrom is the disposition that was requested when
	// Degraded is true ("" otherwise).
	DegradedFrom string `json:"degraded_from,omitempty"`
	// DegradationReason is the typed cause when Degraded is true
	// (`unknown_tool` / `inline_unsupported_mime` /
	// `invalid_disposition`).
	DegradationReason string `json:"degradation_reason,omitempty"`
	// Tool is the unknown tool name when DegradationReason is
	// `unknown_tool`.
	Tool string `json:"tool,omitempty"`
	// OccurredAt is the resolution wall-clock instant.
	OccurredAt time.Time `json:"occurred_at"`
}

InputDispositionResolvedPayload is the `task.input_disposition.resolved` event payload. SafePayload — the fields are artifact ref / MIME / disposition / layer metadata plus the identity quadruple; no operator content and no tool arguments, so the audit redactor bypasses it and subscribers keep typed access (the `llm.image.materialized` precedent).

type Option added in v1.8.0

type Option func(*runContextConfig)

Option configures a NewRunContext call. The functional-option shape keeps the factory signature stable as new per-run knobs land.

func WithDispositionPolicy added in v1.8.0

func WithDispositionPolicy(policy planner.DispositionPolicy) Option

WithDispositionPolicy supplies the per-agent disposition policy map (the middle precedence layer).

func WithInputArtifactDispositions added in v1.8.0

func WithInputArtifactDispositions(hints map[string]string) Option

WithInputArtifactDispositions supplies the per-attachment disposition hint map (artifact ID → "inline"/"ref"/"tool:<name>") — the top precedence layer of disposition resolution.

func WithInputArtifacts added in v1.8.0

func WithInputArtifacts(ids ...string) Option

WithInputArtifacts pre-resolves the operator-uploaded artifact IDs into the run's first-turn multimodal inputs (the materializer renders them as Content.Parts). Without it the run is text-only.

type Sources added in v1.8.0

type Sources struct {
	// Memory is the session-scoped memory store. When non-nil, the
	// session's rolling-summary + recent turns are projected into
	// RunContext.MemoryBlocks; semantic recall fires when MemoryRecall
	// is enabled.
	Memory       memory.MemoryStore
	MemoryRecall memory.RecallSettings

	// SkillsDirectory is the bounded, capability-filtered browse window
	// projected into RunContext.SkillsContext. When non-nil its View is
	// taken under the run's identity scope and the run's visible-tool
	// set (derived from Catalog + GrantedScopes).
	SkillsDirectory *skills.Directory

	// Catalog is the production tool catalog. When non-nil it is
	// projected into the planner-facing schema-only RunContext.Catalog
	// view under the run's identity + granted-scope filter.
	Catalog tools.ToolCatalog

	// Artifacts is the artifact store the input-artifact materializer
	// reads from (only touched when WithInputArtifacts supplies IDs).
	Artifacts artifacts.ArtifactStore

	// Bus is the event bus the planner-side telemetry emitter and the
	// per-token chunk publisher write through. When nil, RunContext.Emit
	// and RunContext.OnChunk are left nil (no streaming surface).
	Bus events.EventBus

	// Logger receives the projection helpers' Debug/Warn records. Nil
	// falls back to slog.Default().
	Logger *slog.Logger

	// GrantedScopes is the operator-declared authorization-scope list
	// threaded into the catalog + skills capability filters. Tools whose
	// AuthScopes exceed this set are invisible to the run.
	GrantedScopes []string

	// PlanningHints is the optional runtime-supplied planning-constraint
	// bundle threaded straight onto RunContext.PlanningHints.
	PlanningHints *planner.PlanningHints

	// LLMOverrides is the optional run-start-resolved LLM-parameter
	// bundle. A headless caller with no tenant/agent control plane wired
	// leaves it nil (the run uses the configured defaults).
	LLMOverrides *planner.LLMOverrides

	// Budget is the per-run hard caps (token budget, deadline, hop
	// budget) projected onto RunContext.Budget. The zero value disables
	// compression and applies the runtime defaults.
	Budget planner.Budget

	// OutputSchema is the optional, opt-in run-level output schema (raw
	// JSON-Schema bytes). When non-empty NewRunContext compiles it ONCE
	// and threads the compiled validator onto RunContext.OutputSchema, so
	// the terminal answer is validated against it (fail-loud). A compile
	// failure fails the run loudly rather than silently degrading to an
	// unconstrained run (CLAUDE.md §13). Empty means "no schema".
	OutputSchema json.RawMessage
}

Sources carries the stack-derived subsystem handles NewRunContext projects into a planner.RunContext. Every field is OPTIONAL except an identity-complete quadruple (passed separately): a nil subsystem leaves the corresponding RunContext field nil and the planner omits the matching wrapper — exactly as the run-loop drivers behave when a dev stack did not open the subsystem.

assemble.Stack.RunOnce populates Sources from the assembled stack; a headless embedder constructing its own RunSpec populates it by hand.

Jump to

Keyboard shortcuts

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