react

package
v1.3.0 Latest Latest
Warning

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

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

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:

  1. Honours ctx.Err() and the run's identity quadruple (§6 rule 9 + D-001 — identity is mandatory; the runtime fails closed).
  2. 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.
  3. 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).
  4. 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.
  5. 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`.
  6. 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).
  7. 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.
  8. 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).
  9. Issues exactly ONE llm.LLMClient.Complete call. On error, surfaces it verbatim (§13 fail-loudly).
  10. 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}.
  11. 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).
  12. Emits planner.EventTypePlannerDecision carrying the resolved Decision shape + the captured reasoning trace.
  13. 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

View Source
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.

View Source
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.

View Source
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.

View Source
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).

View Source
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).

View Source
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).

View Source
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.

View Source
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

func WithArgFillEnabled(b bool) Option

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

func WithMaxConsecutiveArgFailures(n int) Option

WithMaxConsecutiveArgFailures passes the repair.Config.MaxConsecutiveArgFailures storm-guard counter through to Phase 44's loop. Default repair.DefaultMaxConsecutiveArgFailures (2).

func WithMaxSteps

func WithMaxSteps(n int) Option

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

func WithMaxToolExamplesPerTool(n int) Option

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

func WithParallelToolCalls(b bool) Option

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

func WithRepairAttempts(n int) Option

WithRepairAttempts passes the repair.Config.RepairAttempts knob through to Phase 44's loop. Default repair.DefaultRepairAttempts (3).

func WithSystemPrompt

func WithSystemPrompt(s string) Option

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

func WithSystemPromptExtra(s string) Option

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

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).

Jump to

Keyboard shortcuts

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