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).
Phase 107c (D-167) cut the planner over to provider-native tool- calling: each per-step llm.CompleteRequest carries the visible catalog in `req.Tools`, and the response's typed llm.CompleteResponse.ToolCalls slice drives planner.Decision directly via [projectResponse]. The Phase 44 repair.RepairLoop remains in-tree for the `declarative_action` escape-hatch meta-tool (step 10 wires the dispatch); the main React path no longer parses JSON envelopes out of `resp.Content` and no longer runs the salvage / schema-repair / multi-action-salvage ladder for native responses (AC-15 / AC-19 / AC-20c).
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).
- Drains planner.RunContext.PendingToolCalls (AC-19a). When the prior step's projection accumulated extra ToolCalls (the multi- ToolCall serialization fallback per AC-19), the planner emits them one at a time BEFORE consulting the LLM again.
- Derives the per-run discovered-tools set (AC-18) by walking prior `tool_search` results in planner.RunContext.Trajectory, stamps the union into planner.RunContext.DiscoveredTools for observability, and uses it as the deferred-loading surface for this turn's `req.Tools`.
- Builds the llm.CompleteRequest via the configured PromptBuilder. The default builder (Phase 83a, reshaped by Phase 107c) assembles the nine XML-tagged structured sections — `<identity>`, `<tool_discovery>`, `<tool_usage>`, `<reasoning>`, `<tone>`, `<error_handling>`, `<available_tools>` (name + description quick-reference; schemas live in `req.Tools`), and the optional `<additional_guidance>` / `<planning_constraints>` injection surfaces. The prompt asks for a final answer as plain `resp.Content` and tool steps as native `resp.ToolCalls`; the Phase 83e `{tool, args}` JSON envelope is RETIRED on the main path (declarative_action keeps it for backward compat).
- Stamps `req.Tools` from `rc.Catalog.List()` (which already filters by the run's identity scope + `LoadingAlways` per `tools.PlannerView`) plus per-run discovered tools resolved through `rc.Catalog.Resolve` (AC-17). Sets `req.ParallelToolCalls = true` so native parallel tool-call emission is enabled per turn.
- Wires the per-step streaming callbacks (planner.RunContext.OnChunk) into `req.Stream` / `req.OnContent` / `req.OnReasoning` (Phase 107). The wiring formerly lived inside the repair loop; under the cutover the planner owns it (the repair loop is no longer called on the native path).
- Issues exactly ONE llm.LLMClient.Complete call. On error, surfaces it verbatim (§13 fail-loudly).
- Routes the response through [projectResponse] (AC-15 / AC-19): - `len(resp.ToolCalls) == 1` → planner.CallTool (or reserved-name translation to planner.Finish / planner.SpawnTask / planner.AwaitTask). - `len(resp.ToolCalls) > 1` → first call becomes planner.CallTool; remainder accumulates on `rc.PendingToolCalls` for serialised dispatch on subsequent steps (AC-19 serialization fallback). - `len(resp.ToolCalls) == 0 && resp.Content != ""` → planner.Finish{Reason: planner.FinishGoal, Payload: resp.Content} (terminal answer as plain content). - `len(resp.ToolCalls) == 0 && resp.Content == ""` → planner.Finish{Reason: planner.FinishNoPath}.
- Threads the captured `resp.Reasoning` through planner.RunContext.OnReasoning (Phase 83m item 8) so the runloop's trajectory-append path stamps `trajectory.Step.ReasoningTrace` (Phase 83e — D-148 replay).
- Emits planner.EventTypePlannerDecision carrying the resolved Decision shape + the captured reasoning trace.
- Resets `rc.RepairCounters` for the step (a clean native step clears any prior turn's repair guidance — the declarative_action path in step 10 is the only producer that will increment them).
**Pause/resume.** Native tool-calling does not change the planner.RequestPause contract; ReAct still doesn't emit RequestPause directly in V1.3 (the unified pause primitive's first React consumer lands with the HITL-approval wave). Pause requests arrive via planner.RunContext.Control and are honoured at step boundary by the planner concrete that emits them.
**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 WithParallelToolCalls(b bool) 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 projector translates the native ToolCall directly to a typed planner.AwaitTask Decision.
const DeclarativeActionToolName = "declarative_action"
DeclarativeActionToolName is the canonical built-in meta-tool name the React planner inspects for repair-outcome signals (Phase 107c step 10 — AC-13 + AC-20c). When the runloop dispatches `declarative_action`, the meta-tool body classifies its outcome into a `repair_outcome` field on its observation. The planner walks the last trajectory step at the START of its next `Next()` invocation and — when that step's Action is `CallTool{declarative_action}` — reads the outcome from the observation and bumps the per-run `RepairCounters` accordingly.
The constant lives here (not imported from `internal/tools/builtin`) to keep the React package's import graph free of `internal/tools/...` concrete dependencies. A drift test in `declarative_outcomes_test.go` pins the constant against the authoritative builtin name.
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 projector translates the native ToolCall carrying this name directly to a typed planner.SpawnTask Decision — the runtime never sees `"_spawn_task"` as a real tool call.
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 WithParallelToolCalls ¶ added in v1.2.0
WithParallelToolCalls toggles native parallel tool-call emission (Phase 107d — D-169). Default `true`: when the LLM returns N>1 tool-calls in one response, the projector emits a native planner.CallParallel and the runtime executor dispatches the branches concurrently. `false`: the Phase 107c serialization fallback fires instead — the head becomes a planner.CallTool and the tail is queued on planner.RunContext.PendingToolCalls, one dispatch per step. The reserved-name co-occurrence guard (AC-21) is independent of this knob and fires in both modes.
The runtime wires this from `config.PlannerConfig.ParallelToolCalls` (nil → true). Read-only after construction (D-025).
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.
**Native tool-calling path (Phase 107c — D-167).** Next issues exactly ONE llm.LLMClient.Complete and routes the response through [projectResponse]. The Phase 44 repair.RepairLoop is not called on the main path; the declarative_action escape-hatch (step 10) is the only consumer that re-enters the loop. A response with no ToolCalls and no Content maps to planner.Finish{NoPath}; non-empty Content with no ToolCalls is a natural-language terminal answer (planner.Finish{Goal, Payload: resp.Content}).
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).