Documentation
¶
Overview ¶
Package deterministic ships Harbor's second concrete Planner (Phase 48 — RFC §6.2 + RFC §11 Q-6 — the iface-validation lens that proves the `internal/planner.Planner` seam is genuinely swappable).
The same Runtime that drives the Phase 45 LLM-driven ReAct concrete drives this deterministic concrete via the identical `Planner` interface, the identical `RunContext` view, and the identical `Decision` sum. NO Runtime change. NO interface change. NO `Decision` shape change. CLAUDE.md §1 property 3 holds.
Decision-tree model ¶
The deterministic planner is configured with an ordered slice of DecisionTreeStep values. Each step has a `Decide(ctx, rc) (Decision, bool, error)` method; the boolean reports whether the step claimed the current `Next` call. On every `Next`, the walker:
- Honours `ctx.Err()`.
- Validates planner.RunContext.Quadruple (§6 rule 9 + D-001; fail-loudly per §13 with wrapped planner.ErrIdentityRequired).
- Observes [planner.RunContext.Control.Cancelled] — returns `Finish{Cancelled}` at the step boundary per RFC §6.3.
- Walks the step set in order. First step returning `(decision, true, nil)` wins. Steps returning `(nil, false, nil)` are skipped. A step returning `(_, _, err)` propagates the error wrapped with planner.ErrDeterministicStep (fail-loudly — no silent skip).
- If no step claims the call, the walker returns `Finish{NoPath, Metadata["deterministic"]="no_step_matched"}`. A misconfigured tree surfaces as a typed terminal, NEVER a silent loop (§13).
Wake-on-resolution (D-032) ¶
The deterministic planner declares planner.WakePoll via the planner.WakeAware interface. The WatchGroupStep (and the composed SpawnAndAwaitStep after its first invocation) perform a non-blocking receive against the channel returned by tasks.TaskRegistry.WatchGroup:
- Channel not yet readable → emit `AwaitTask{TaskID: OwnerTaskID}`; the runtime will re-invoke `Next` at the next deterministic boundary.
- Channel readable → consume the typed tasks.GroupCompletion's `Members` slice and invoke the operator-supplied `OnResolved` callback whose return value is the planner's decision.
The TaskRegistry stays NEUTRAL (D-032): no `WakeMode` field on registry types, no `Supports*` capability protocol. The choice is the planner concrete's; the deterministic concrete picks `poll` because the WakePoll mode is the on-disk proof that the registry's mode-neutral surface accepts a poller.
Concurrent reuse (D-025) ¶
DeterministicPlanner is a reusable artifact: the receiver is read-only after construction. Per-run state lives on the stack and in the planner.RunContext argument. SpawnAndAwaitStep holds an internal `sync.Map` for per-`(SessionID, StepID)` spawn-tracking, keyed so concurrent reuse across runs is safe. `d025_test.go` pins N=128 invocations under `-race`.
Import-graph contract (§13) ¶
The deterministic package MUST NOT import `internal/runtime/...` or `internal/llm/...`. The Phase 42 internal/planner/conformance.TestImportGraph_PlannerDoesNotImportRuntime covers the new package by construction; `scripts/smoke/phase-48.sh` asserts the same via grep on both forbidden prefixes.
Index ¶
Constants ¶
const DefaultName = "deterministic"
DefaultName is the DeterministicPlanner.Name returned when WithName is not supplied.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type CallToolStep ¶
type CallToolStep struct {
// Tool is the tool name registered in the ToolCatalogView. The
// runtime executor dispatches via Phase 26's catalog.
Tool string
// ArgsBuilder constructs the JSON-encoded args payload from the
// run context. Required; nil → step error.
ArgsBuilder func(planner.RunContext) (json.RawMessage, error)
// When is the optional guard. nil → always match. Non-nil → step
// claims the call only when the guard returns true.
When func(planner.RunContext) bool
}
CallToolStep is the operator-configured single-tool dispatch step. When `When` is nil (or returns true), the step claims the call and returns a planner.CallTool decision built from the tool name and the args-builder closure.
`ArgsBuilder` is required: a CallToolStep with a nil ArgsBuilder returns a step error from [Decide] (fail-loudly per §13 — a silent default-args behaviour would mask operator bugs).
Phase 83e (D-147) narrowed `planner.CallTool` to `{tool, args}` — the former `Reasoning` field was removed. A deterministic planner emits no provider-side reasoning, so the step carries no reasoning either.
func (*CallToolStep) Decide ¶
func (s *CallToolStep) Decide(_ context.Context, rc planner.RunContext) (planner.Decision, bool, error)
Decide implements DecisionTreeStep.
type DecisionTreeStep ¶
type DecisionTreeStep interface {
Decide(ctx context.Context, rc planner.RunContext) (planner.Decision, bool, error)
}
DecisionTreeStep is the operator-configurable step abstraction the deterministic planner walks per `Next` call. Each step's [Decide(ctx, rc)] returns:
- `(decision, true, nil)` — the step claims the call; the planner returns the decision verbatim.
- `(nil, false, nil)` — the step is skipped; the planner advances to the next configured step.
- `(_, _, err)` — fail-loudly; the planner wraps the error with planner.ErrDeterministicStep and returns it. NO silent skip.
Implementations MUST be safe for concurrent use. The DeterministicPlanner is a reusable artifact (D-025); the same step instance receives N concurrent invocations from N concurrent runs against the shared planner.
type DeterministicPlanner ¶
type DeterministicPlanner struct {
// contains filtered or unexported fields
}
DeterministicPlanner is Harbor's second concrete Planner. The receiver is read-only after construction; per-call state lives in `ctx` + planner.RunContext. See package godoc for the wake-mode contract and the decision-tree walker semantics.
func NewDeterministicPlanner ¶
func NewDeterministicPlanner(opts ...Option) (*DeterministicPlanner, error)
NewDeterministicPlanner constructs a DeterministicPlanner. Returns wrapped planner.ErrInvalidConfig when:
- the configured step set is empty;
- any configured step is group-aware (SpawnAndAwaitStep / WatchGroupStep) and WithRegistry was not supplied;
- any configured step is nil.
Fail-loudly per §13: configuration errors surface at construction time, NEVER at `Next` time.
func (*DeterministicPlanner) Name ¶
func (p *DeterministicPlanner) Name() string
Name returns the planner's human-readable identifier.
func (*DeterministicPlanner) Next ¶
func (p *DeterministicPlanner) Next(ctx context.Context, rc planner.RunContext) (planner.Decision, error)
Next implements planner.Planner. The flow is documented in the package godoc.
func (*DeterministicPlanner) WakeMode ¶
func (p *DeterministicPlanner) WakeMode() planner.WakeMode
WakeMode declares the planner's wake-on-resolution strategy (D-032 + Phase 48 spec). Deterministic ships the `poll` mode: each `Next` invocation performs a non-blocking receive on its outstanding group's tasks.TaskRegistry.WatchGroup channel; not ready → emit `AwaitTask`, the runtime sleeps the step until the next deterministic boundary; ready → consume `MemberOutcome` and proceed. No LLM, no eager wake — a clean deterministic shape that proves the registry's `WatchGroup` surface is mode-neutral.
type FinishStep ¶
type FinishStep struct {
// Reason is the terminal reason. MUST be canonical (see
// planner.IsValidFinishReason).
Reason planner.FinishReason
// PayloadBuilder constructs the terminal payload from the run
// context. nil → nil Payload.
PayloadBuilder func(planner.RunContext) (any, error)
// MetadataBuilder constructs the terminal metadata from the run
// context. nil → nil Metadata (the step still stamps `run_id`
// when the run has one — see Decide).
MetadataBuilder func(planner.RunContext) (map[string]any, error)
// When is the optional guard. nil → always match.
When func(planner.RunContext) bool
}
FinishStep is the operator-configured terminal step. When `When` is nil (or returns true), the step claims the call and returns a planner.Finish decision built from the configured reason, the payload-builder closure, and the metadata-builder closure.
`Reason` MUST be one of the canonical planner.FinishReason values; an invalid reason surfaces as a step error.
func (*FinishStep) Decide ¶
func (s *FinishStep) Decide(_ context.Context, rc planner.RunContext) (planner.Decision, bool, error)
Decide implements DecisionTreeStep.
type Option ¶
type Option func(*config)
Option configures a DeterministicPlanner at construction time.
func WithName ¶
WithName sets the planner's human-readable identifier (audit + observability). Default: DefaultName.
func WithRegistry ¶
func WithRegistry(reg tasks.TaskRegistry) Option
WithRegistry sets the tasks.TaskRegistry handle that group-aware steps poll via tasks.TaskRegistry.WatchGroup. Required when any configured step is a SpawnAndAwaitStep or a WatchGroupStep; NewDeterministicPlanner returns wrapped planner.ErrInvalidConfig if a group-aware step is configured without a registry.
func WithSteps ¶
func WithSteps(steps ...DecisionTreeStep) Option
WithSteps sets the ordered decision-tree step set. At least one step is required at construction time; an empty set surfaces as wrapped planner.ErrInvalidConfig from NewDeterministicPlanner.
type PauseStep ¶
type PauseStep struct {
// Reason is the pause reason. MUST be canonical (see
// planner.IsValidPauseReason).
Reason planner.PauseReason
// PayloadBuilder constructs the pause payload from the run
// context. nil → empty Payload map.
PayloadBuilder func(planner.RunContext) (map[string]any, error)
// When is the optional guard. nil → always match.
When func(planner.RunContext) bool
}
PauseStep is the operator-configured pause-request step. When `When` is nil (or returns true), the step claims the call and returns a planner.RequestPause decision built from the configured reason and the payload-builder closure.
`Reason` MUST be one of the canonical planner.PauseReason values; an invalid reason surfaces as a step error.
type SpawnAndAwaitStep ¶
type SpawnAndAwaitStep struct {
// StepID is the step's unique identifier within the planner's
// configured step set. Used as the per-(SessionID, StepID) key
// for the spawn-state map. Default: "<spawn-and-await>".
StepID string
// Kind is the [tasks.TaskKind] for the spawned member task.
// Typically [tasks.KindBackground].
Kind tasks.TaskKind
// SpecBuilder constructs the [planner.SpawnSpec] from the run
// context. Required; nil → step error.
SpecBuilder func(planner.RunContext) (planner.SpawnSpec, error)
// GroupID is the optional pre-assigned group identifier. Empty
// → the step creates an ad-hoc group keyed by
// `(SessionID, StepID)`.
GroupID tasks.TaskGroupID
// OnResolved is invoked once the group reaches a terminal
// state. The returned decision becomes the planner's next
// decision. Required; nil → step error.
OnResolved func(planner.RunContext, []tasks.MemberOutcome) (planner.Decision, error)
// When is the optional guard. nil → always match (claim the
// call until the step is resolved).
When func(planner.RunContext) bool
// contains filtered or unexported fields
}
SpawnAndAwaitStep is the operator-configured spawn-then-await step. The step ships the load-bearing scenario the §13 primitive- with-consumer policy demands for Phase 48: SpawnTask + AwaitTask are emitted by a real concrete planner against a real tasks.TaskRegistry.
Lifecycle (per `(SessionID, StepID)`):
- First [Decide] call → claim, emit planner.SpawnTask built from `SpecBuilder`. The step resolves an ad-hoc group via tasks.TaskRegistry.ResolveOrCreateGroup (when `GroupID` is empty) and spawns a member task via tasks.TaskRegistry.Spawn. The step persists the assigned `(GroupID, TaskID)` in its internal sync.Map.
- Subsequent [Decide] calls perform a non-blocking receive against the group's tasks.TaskRegistry.WatchGroup channel: - not yet ready → emit planner.AwaitTask{TaskID: ownerTaskID} so the runtime sleeps the step until the next deterministic boundary (WakePoll semantics, D-032). - ready → invoke `OnResolved(rc, members)`; the returned decision flows through. Once OnResolved fires, the step is MARKED resolved — future `Decide` calls with the same `(SessionID, StepID)` SKIP (return `(nil, false, nil)`).
`StepID` MUST be unique within the planner's configured step set. When empty, the step uses `"<spawn-and-await>"` — fine for a single instance but ambiguous if two `SpawnAndAwaitStep` values are configured; operators with multiple group-aware steps SHOULD set distinct StepIDs.
`OnResolved` MUST be safe for concurrent use — the same step instance is shared across N concurrent runs against the planner.
func (*SpawnAndAwaitStep) Decide ¶
func (s *SpawnAndAwaitStep) Decide(ctx context.Context, rc planner.RunContext) (planner.Decision, bool, error)
Decide implements DecisionTreeStep. See type godoc for the lifecycle semantics.
type WatchGroupStep ¶
type WatchGroupStep struct {
// GroupID is the pre-existing group identifier.
GroupID tasks.TaskGroupID
// OwnerTaskID is the task whose lifecycle the planner reports
// as the AwaitTask target. Typically the spawn handle's TaskID
// surfaced by an earlier SpawnTask emission.
OwnerTaskID tasks.TaskID
// OnResolved is invoked once the group reaches a terminal
// state. Required; nil → step error.
OnResolved func(planner.RunContext, []tasks.MemberOutcome) (planner.Decision, error)
// When is the optional guard. nil → always match.
When func(planner.RunContext) bool
// contains filtered or unexported fields
}
WatchGroupStep is the operator-configured "I am waiting on this pre-existing group" step. Distinct from SpawnAndAwaitStep: this step does NOT spawn — it expects the group to exist already (the runtime engine, or another planner step earlier in the tree, created it). The step performs the WakePoll non-blocking receive per `Decide` call:
- not yet ready → emit planner.AwaitTask{TaskID: OwnerTaskID}.
- ready → invoke `OnResolved(rc, members)`; once it fires the step is marked resolved and SKIPS future calls of the same `(SessionID, OwnerTaskID)`.
func (*WatchGroupStep) Decide ¶
func (s *WatchGroupStep) Decide(ctx context.Context, rc planner.RunContext) (planner.Decision, bool, error)
Decide implements DecisionTreeStep.