Documentation
¶
Overview ¶
Package react ships Harbor's reference LLM-driven planner concrete (Phase 45 — RFC §6.2 + RFC §3.2 — the first concrete sitting on the `internal/planner.Planner` seam).
Each ReActPlanner.Next call:
- Honours ctx.Err() and the run's identity quadruple (§6 rule 9 + D-001 — identity is mandatory; the runtime fails closed).
- Checks the [MaxSteps] circuit breaker. When the run's prior trajectory carries ≥ MaxSteps recorded steps, the planner emits planner.EventTypePlannerMaxStepsExceeded AND returns planner.Finish{Reason: planner.FinishNoPath, Metadata["max_steps_exceeded"]=true}. Fail-loudly per §13.
- Observes [planner.RunContext.Control.Cancelled]; returns planner.Finish{Reason: planner.FinishCancelled} on a CANCEL observation (the planner's step-boundary contract per RFC §6.3).
- Builds the llm.CompleteRequest via the configured PromptBuilder. The default builder (Phase 83a — brief 13 §2.1) assembles the twelve XML-tagged structured sections and asks the LLM for a JSON envelope `{"tool":"<name>","args":{...}}` per step OR `{"tool":"_finish","args":{"answer":"..."}}` to signal completion. The envelope carries NO `reasoning` field — reasoning is captured from the provider channel, never required in the structured output (brief 13 §2.6).
- Delegates the response → planner.Decision mapping to Phase 44's repair.RepairLoop.Run (salvage → schema repair → graceful failure → multi-action salvage). The repair loop is OUTSIDE the LLM call; the Phase 36 retry-with-feedback wrapper is INSIDE — composition stays at the registry edge (D-043 + D-050).
- Maps the repair loop's planner.Decision to the planner's final shape: - `Finish{NoPath}` from the loop → propagate verbatim (planner.EventTypePlannerRepairExhausted already emitted by the loop's [repair.RepairLoop.gracefulFailure]). - `CallTool` with `Tool == "_finish"` → translate to `Finish{Reason: FinishGoal, Payload: <args.answer>}`. The reserved name is a prompt-time convention, NOT a magic- string opcode in the Decision sum (D-047 + D-051; the predecessor's `next_node` anti-pattern is rejected). - `CallTool` with `Tool == "_spawn_task"` → translate to planner.SpawnTask with the args decoded into planner.SpawnSpec (Phase 47, D-056). Wake mode is push: a non-retain-turn SpawnTask returns control to the runtime; on tasks.GroupCompletion the runtime re-enters Next with the resolved MemberOutcome surfaced through `RunContext.Trajectory.Background`. - `CallTool` with `Tool == "_await_task"` → translate to planner.AwaitTask keyed on the args' `task_id` (Phase 47, D-056). - `CallTool` with another tool name → return verbatim. - `CallParallel` (multi-action salvage from Phase 44) → pass through verbatim. Phase 47 (D-056) ships the runtime parallel executor that consumes this shape; the V1 single-tool-call- per-step collapse override (the Phase 45 D-051 stop-gap) is DELETED — Harbor's V1 ceiling lifts here.
**Wake-on-resolution (D-032).** ReActPlanner implements planner.WakeAware returning planner.WakePush. Phase 47 wires the emission path end-to-end (D-056): a non-retain-turn `_spawn_task` emission returns control to the runtime; the runtime registers the planner against tasks.TaskRegistry.WatchGroup; on the tasks.GroupCompletion delivery the runtime re-invokes `Next` with the resolved `MemberOutcome` slice surfaced through `RunContext.Trajectory.Background`. The conformance pack (Phase 49) asserts the round-trip:
planner.ResolveWakeMode(reactPlanner) == planner.WakePush
**Concurrent-reuse (D-025).** ReActPlanner is a reusable artifact: one constructed instance is safe to share across N concurrent runs. The receiver is read-only after construction; per-call state lives on the stack and in the run's planner.RunContext. `d025_test.go` pins N=128 invocations under `-race`.
**Import-graph contract (§13).** The react package MUST NOT import `internal/runtime/...`. The Phase 42 internal/planner/conformance.TestImportGraph_PlannerDoesNotImportRuntime covers the new package by construction (it walks the whole planner subtree). The Phase 45 smoke script asserts the same via grep.
Index ¶
- Constants
- type Option
- func WithArgFillEnabled(b bool) Option
- func WithMaxConsecutiveArgFailures(n int) Option
- func WithMaxSteps(n int) Option
- func WithMaxToolExamplesPerTool(n int) Option
- func WithPromptBuilder(b PromptBuilder) Option
- func WithReasoningReplay(mode planner.ReasoningReplayMode) Option
- func WithRepairAttempts(n int) Option
- func WithSystemPrompt(s string) Option
- func WithSystemPromptExtra(s string) Option
- type PromptBuilder
- type ReActPlanner
- type RepairTier
Constants ¶
const ( // ReminderFinishGuidance — finish-repair counter == 1. ReminderFinishGuidance = "reminder: your previous `_finish` action failed validation. " + "When you finish, emit exactly `{\"tool\": \"_finish\", \"args\": {\"answer\": \"...\"}}` " + "with `answer` as the only field — plain text, no metadata." // WarningFinishGuidance — finish-repair counter == 2. WarningFinishGuidance = "warning: your `_finish` action has failed validation twice. " + "Re-read the <finishing> section. The `args` object must contain ONLY the `answer` key, " + "and `answer` must be a plain-text string. Do not add status, confidence, or route fields." // CriticalFinishGuidance — finish-repair counter >= 3. CriticalFinishGuidance = "critical: your `_finish` action has failed validation three or more times. " + "Stop and emit this exact shape, substituting only the answer text: " + "`{\"tool\": \"_finish\", \"args\": {\"answer\": \"<your full plain-text answer here>\"}}`. " + "No other keys. No code fence commentary." // ReminderArgsGuidance — args-repair counter == 1. ReminderArgsGuidance = "reminder: your previous tool call had arguments that failed the tool's schema. " + "Match every argument name and type to the tool's `args_schema` exactly before calling it again." // WarningArgsGuidance — args-repair counter == 2. WarningArgsGuidance = "warning: your tool arguments have failed schema validation twice. " + "Re-read the chosen tool's `args_schema` in <available_tools>. Include every required field, " + "use the exact field names, and match the declared types — strings quoted, numbers unquoted." // CriticalArgsGuidance — args-repair counter >= 3. CriticalArgsGuidance = "critical: your tool arguments have failed schema validation three or more times. " + "Pick the simplest tool that can make progress, copy its `args_schema` field-for-field, " + "and supply only the fields that schema declares. If no tool fits, `_finish` with an explanation." // ReminderMultiActionGuidance — multi-action counter == 1. ReminderMultiActionGuidance = "reminder: your previous response contained more than one JSON action block. " + "Emit exactly ONE JSON object per turn, inside a single ```json code fence." // WarningMultiActionGuidance — multi-action counter == 2. WarningMultiActionGuidance = "warning: you have emitted multiple JSON action blocks twice. " + "One turn = one action. To run tools concurrently, use a single `parallel` action — " + "never several separate JSON objects." // CriticalMultiActionGuidance — multi-action counter >= 3. CriticalMultiActionGuidance = "critical: you have emitted multiple JSON action blocks three or more times. " + "Respond with ONE and only one JSON object. If you need concurrent tool calls, " + "wrap them in a single `{\"tool\": \"parallel\", \"args\": {\"steps\": [...]}}` action." )
Repair-guidance hint copy — Phase 83c (D-145). The copy lives in exported constants so operators can grep it and so a copy change shows up as a reviewable diff (the nine golden fixtures under testdata/repair_guidance/ pin the rendered bodies). Each tier opens with its own tier name so a copy-paste typo that reuses the wrong tier's text is caught by the smoke script.
Copy-design note (brief 13 §2.2 risk): an over-aggressive `critical` hint can confuse the model. The copy escalates in firmness, not in volume — `critical` is direct and specific, not shouty.
const AwaitTaskToolName = "_await_task"
AwaitTaskToolName is the reserved tool name the LLM emits to block the foreground turn on a previously-spawned task. The planner intercepts this in mapDecision and translates it to a typed planner.AwaitTask Decision. D-056 — Phase 47.
const DefaultMaxSteps = 12
DefaultMaxSteps is the planner-side circuit-breaker default for the observed trajectory step count. Set small enough to surface bugs quickly; large enough to leave 3-step scenarios headroom. The runtime's hop / cost budget (Phase 47+) is the authoritative gate; the planner-side cap is defence in depth (§13 + D-051).
const DefaultSystemPrompt = "harbor.react.default-system-prompt"
DefaultSystemPrompt is the sentinel value the planner sends as the leading system-prompt argument when WithSystemPrompt is not set.
Phase 83a (RFC §6.2, brief 13 §2.1) replaced the former flat-string prompt with the twelve XML-tagged structured sections assembled by `defaultBuilder.buildSystemContent`. The structured sections ARE the default prompt content; this constant is the routing sentinel the builder compares against to decide whether to emit the structured twelve-section layout (sentinel matched → structured) or to honour an operator's verbatim WithSystemPrompt override (any other value → verbatim). The constant value is intentionally a stable non-empty string, never empty: `New` seeds `systemPrompt` with it, `WithSystemPrompt("")` falls back to it, and `buildSystemContent` branches on identity-equality with it.
The old single-string Phase 45/47 prompt constant is intentionally removed (not renamed to `legacyDefaultSystemPrompt`) — the golden fixture `testdata/golden_default_prompt.txt` is the normative spec for the rendered default prompt going forward, and a dangling legacy constant would be dead code (CLAUDE.md §13).
const DriverName = "react"
DriverName is the canonical name the react planner registers under. The `internal/config` validator's `allowedPlannerDrivers` allowlist mirrors this constant (D-103). `cmd/harbor/main.go` blank-imports this package so the registration fires at process boot (§4.4 seam pattern; D-095 OAuth-provider precedent).
const FinishToolName = "_finish"
FinishToolName is the reserved tool name the LLM emits to signal completion. The planner intercepts this BEFORE returning the Decision; `"_finish"` never reaches the runtime as a real tool call. The leading underscore is a documented convention; future runtime catalog registration MAY reject `_`-prefixed tool names. D-051.
const SpawnTaskToolName = "_spawn_task"
SpawnTaskToolName is the reserved tool name the LLM emits to spawn a background task. The planner intercepts this in mapDecision and translates it to a typed planner.SpawnTask Decision before returning — the runtime never sees `"_spawn_task"` as a real tool call. D-056 — Phase 47 adds the third reserved emission shape.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Option ¶
type Option func(*ReActPlanner)
Option configures a ReActPlanner at construction time. Options are applied in order; later options override earlier ones.
func WithArgFillEnabled ¶
WithArgFillEnabled toggles Phase 44's schema-repair path. When false, the loop surfaces the parser's first action verbatim and lets the dispatcher reject misshaped args. Default true.
func WithMaxConsecutiveArgFailures ¶
WithMaxConsecutiveArgFailures passes the repair.Config.MaxConsecutiveArgFailures storm-guard counter through to Phase 44's loop. Default repair.DefaultMaxConsecutiveArgFailures (2).
func WithMaxSteps ¶
WithMaxSteps overrides the DefaultMaxSteps circuit-breaker cap. Values ≤ 0 fall back to DefaultMaxSteps. The breaker fires when `len(rc.Trajectory.Steps) >= MaxSteps`; the planner emits planner.EventTypePlannerMaxStepsExceeded AND returns `Finish{NoPath, Metadata["max_steps_exceeded"]=true}`.
func WithMaxToolExamplesPerTool ¶
WithMaxToolExamplesPerTool caps how many curated examples each tool renders in the `<available_tools>` section of the system prompt (Phase 83b — D-144). The runtime wires this from `config.PlannerConfig.MaxToolExamplesPerTool`. A value ≤ 0 (the default) resolves to [defaultMaxToolExamples] (3) at render time. Examples are ranked `minimal` > `common` > `edge-case` > untagged; the renderer keeps the top N.
The option applies only when the default prompt builder is in use; an operator-supplied WithPromptBuilder owns its own prompt assembly and ignores this value.
func WithPromptBuilder ¶
func WithPromptBuilder(b PromptBuilder) Option
WithPromptBuilder injects a custom PromptBuilder. Default: the in-package builder. A nil builder is rejected (the option is a no-op).
func WithReasoningReplay ¶
func WithReasoningReplay(mode planner.ReasoningReplayMode) Option
WithReasoningReplay sets the agent-configured reasoning-replay mode (Phase 83e — D-148). The runtime wires this from `config.PlannerConfig.ReasoningReplay`. The default — and the value for an empty / unset mode — is planner.ReasoningReplayNever: a prior step's captured reasoning is NEVER re-injected into the next prompt. planner.ReasoningReplayText opts the agent into prepending each prior step's captured `ReasoningTrace` as a text block above the action JSON. A per-run `RunContext.ReasoningReplay` override wins over this configured value at render time.
An invalid mode is rejected (the option is a no-op) — config validation already rejects bad values pre-boot.
func WithRepairAttempts ¶
WithRepairAttempts passes the repair.Config.RepairAttempts knob through to Phase 44's loop. Default repair.DefaultRepairAttempts (3).
func WithSystemPrompt ¶
WithSystemPrompt overrides the DefaultSystemPrompt. An empty string falls back to DefaultSystemPrompt.
A non-default, non-empty string is honoured verbatim by the default prompt builder: it REPLACES the twelve-section structured layout (the structured sections ARE the default prompt content). The optional injection sections (`<available_tools>`, `<additional_guidance>`, `<planning_constraints>`) still append, so tool rendering and WithSystemPromptExtra guidance survive a custom base prompt.
func WithSystemPromptExtra ¶
WithSystemPromptExtra injects operator-supplied guidance into the `<additional_guidance>` section of the rendered system prompt (Phase 83a, RFC §6.2, brief 13 §2.1 section 11). The string is rendered verbatim; the operator is responsible for content hygiene. An empty (or whitespace-only) string is a no-op — the `<additional_guidance>` section is then omitted from the prompt entirely rather than emitted as an empty tag pair.
The guidance applies only when the default prompt builder is in use; an operator-supplied WithPromptBuilder owns its own prompt assembly and ignores this option. `internal/config`'s `PlannerConfig.ExtraGuidance` key flows to this option at construction (see `internal/planner/react/init.go`).
type PromptBuilder ¶
type PromptBuilder interface {
// Build returns the LLM request to send for the current step.
// The builder reads from rc; it MUST NOT mutate rc. The returned
// request carries Model = "" (the LLM client / wrapper chain
// resolves the configured model at registry edge); callers that
// need to pin a model override can wrap a default builder.
Build(rc planner.RunContext, systemPrompt string) llm.CompleteRequest
}
PromptBuilder constructs the llm.CompleteRequest from a planner.RunContext. Default implementation ships in-package as [defaultBuilder]; operators may inject their own via WithPromptBuilder per RFC §6.2 (the planner's small set of genuinely policy-shaped knobs).
Implementations MUST be safe for concurrent use (the planner is a reusable artifact per D-025; the prompt builder is read on every Next call).
type ReActPlanner ¶
type ReActPlanner struct {
// contains filtered or unexported fields
}
ReActPlanner is Harbor's reference LLM-driven planner. Reusable artifact (D-025): the receiver is read-only after construction; per-call state lives on the stack and in the planner.RunContext.
All fields are set at construction by New (with Option applied); none are mutated by [Next].
func New ¶
func New(client llm.LLMClient, opts ...Option) *ReActPlanner
New constructs a ReActPlanner backed by the supplied llm.LLMClient with the given options applied. Nil client panics — composition error caught at boot.
func (*ReActPlanner) Next ¶
func (p *ReActPlanner) Next(ctx context.Context, rc planner.RunContext) (planner.Decision, error)
Next implements planner.Planner. The flow is documented in the package godoc.
func (*ReActPlanner) StepsTaken ¶
func (p *ReActPlanner) StepsTaken() int64
StepsTaken returns the process-wide count of ReActPlanner.Next invocations served. Used by tests; not part of the planner contract. Atomic load — safe across goroutines.
func (*ReActPlanner) WakeMode ¶
func (p *ReActPlanner) WakeMode() planner.WakeMode
WakeMode declares the planner's wake-on-resolution strategy (D-032 + Phase 45 spec). ReAct ships the `push` mode: a non-retain-turn SpawnTask emission (deferred to a later phase) would return control to the runtime; the runtime would register the planner against tasks.TaskRegistry.WatchGroup; on `GroupCompletion` the runtime would re-invoke `Next` with the resolved `MemberOutcome` surfaced through `RunContext.Trajectory.Background`.
type RepairTier ¶
type RepairTier string
RepairTier names an escalation level for repair guidance. The three tiers map to counter values: 1 → reminder, 2 → warning, >= 3 → critical. A zero counter has no tier (the empty string).
const ( // RepairTierNone is the absence of a tier — the counter is 0, so // no guidance block is rendered. RepairTierNone RepairTier = "" // RepairTierReminder is the first escalation level (counter == 1): // a gentle nudge. RepairTierReminder RepairTier = "reminder" // RepairTierWarning is the second escalation level (counter == 2): // a firmer correction. RepairTierWarning RepairTier = "warning" // RepairTierCritical is the top escalation level (counter >= 3): // the strongest correction copy. RepairTierCritical RepairTier = "critical" )
Repair-guidance escalation tiers (Phase 83c — D-145).