planexec

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 7, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package planexec hosts the reusable plan-and-execute loop shared by the proposal (case-draft) host and the planexec-strategy Job host.

The loop is `plan → executePhase → replan → ... → final` and follows the vocabulary established by secmon-lab/warren's `pkg/usecase/chat/bluebell`: the planner emits a `Plan` (a list of `TaskPlan` to run in parallel), the sub-agents fan out via `executePhase`, and the planner is asked again (`replan`) with the observations. The loop exits when replan returns no further tasks and no question, after which the runtime invokes `generateFinalResponse` for the host-visible terminal output. No "terminal action" discriminator is involved.

Index

Constants

This section is empty.

Variables

View Source
var (
	// RolePlanner is the model that plans and replans.
	RolePlanner = agentkit.DefineModelRole("hecatoncheires.planner")
	// RoleFinalizer is the model that writes the terminal output.
	RoleFinalizer = agentkit.DefineModelRole("hecatoncheires.finalizer")
)

Model roles. A host binds them to a specific model through the Kernel; an unbound role falls back to the Kernel's default model.

Functions

func Register added in v0.3.0

func Register[T Validatable](
	reg *agentkit.Registry, name agentkit.AgentName, version int,
	taskAgent agentkit.Agent[react.Input], progress Progress,
	limiter agentkit.Limiter, cfg Config[T],
	opts ...agentkit.RegisterOption[Output[T]],
) (agentkit.Agent[Input], error)

Register registers the strategy under name and returns the typed handle.

taskAgent is the agent each planned task runs as — a ReAct agent, spawned as a child. limiter is the budget this run answers Limit with; it is required, because a run with no ceiling is a run that can spend without bound. progress may be nil, which draws nothing.

func RenderAnswers added in v0.3.0

func RenderAnswers(q Question, answers []QuestionAnswer) string

RenderAnswers turns a human's answers into the user turn a suspended run continues from. The host calls it and passes the result to Kernel.Respond, so the encoding of an answer stays the host's business and planexec only sees a string.

Each answer is labelled with the question it belongs to, because the planner sees them one round after it asked and an unlabelled list of values is ambiguous when several items were asked at once.

Types

type Asker added in v0.3.0

type Asker interface {
	Ask(ctx context.Context, pid agentkit.ProcessID, meta map[string]string,
		key agentkit.AwaitKey, q Question) error
}

Asker delivers a run's question to whoever can answer it, for a host that waits in-band (Input.SuspendOnQuestion). The host implements it; planexec holds no Slack dependency.

pid and key are what the answer must be Responded to — the host records the pair alongside whatever it posts, because the reply arrives later, out of band, on an instance that never saw this transition. meta is the run's Process metadata, which is what lets one registered Asker serve every run.

It posts before the run suspends, so a transition that is replayed would post twice. That is why every Serve in this application bounds unclean reclaims to 0 (agentkernel.Serve): a replay fails the run instead of re-posting.

type Config added in v0.3.0

type Config[T Validatable] struct {
	// Decode turns the terminal JSON into T. nil uses encoding/json.
	Decode func([]byte) (*T, error)
	// Finalizers run in order after T.Validate(); the first rejection wins.
	Finalizers []Finalizer[T]
	// Asker delivers a question for a host that waits in-band. Required when a run
	// sets Input.SuspendOnQuestion — without one there would be nobody to show the
	// question to, and the run would park on an await nothing can answer.
	Asker Asker
	// TextOnly generates the terminal output as prose instead of JSON and puts it
	// in Output.Text.
	TextOnly bool
	// Remaining reports what this run may still spend and the total it was
	// allowed, so the planner can size the tasks it plans and each child can be
	// given a ceiling of its own.
	//
	// It is a function rather than a value because a strategy is registered once
	// at startup and then serves every run: the figures come from the run's own
	// metadata and metrics, never from anything a registration could close over.
	// Same reason Finalizers take the run's metadata.
	//
	// nil turns the per-task budget off entirely: the planner is shown no budget
	// line, `budget_usd` leaves the task schema and is not validated, and a child
	// is spawned with the metadata it would have had otherwise — its parent's, so
	// it is judged against the ROOT figure on its own metrics.
	Remaining func(meta map[string]string, metrics agentkit.Metrics) (remaining, total pricing.NanoUSD)
}

Config is the host's terminal-output contract.

type DirectPlan

type DirectPlan struct {
	// Tools is the subset of RunRequest.KnownToolIDs the direct agent may
	// call. May be empty for a pure conversational reply that needs no tool.
	// Bounded by maxToolsPerTask.
	Tools []string `json:"tools,omitempty"`
}

DirectPlan is the round-1 "answer directly" payload. It carries only the tools the single direct ReAct agent is permitted to call; everything else about the direct path (system prompt, history, loop limit) is supplied by the runtime, and the response is always plain text — structured-final generation (Run[T]) is not consulted on this path.

func (*DirectPlan) Validate

func (d *DirectPlan) Validate(knownToolIDs []string) error

Validate enforces DirectPlan invariants. knownToolIDs is the host-supplied allowlist (RunRequest.KnownToolIDs); every entry in Tools must be a member. An empty Tools list is allowed — a direct reply need not call any tool.

type FinalizePlan

type FinalizePlan struct {
	// Reason is a 1-sentence rationale for terminating now (optional).
	Reason string `json:"reason,omitempty"`
}

FinalizePlan is the planner's explicit "I'm done" declaration. It carries an optional short rationale; the actual user-visible output is produced by the entry point (final text, or the validated structured object) after the loop exits.

type Finalizer added in v0.3.0

type Finalizer[T any] func(ctx context.Context, meta map[string]string, out *T) error

Finalizer validates a decoded terminal output against context T.Validate() cannot see — a workspace field schema, say. A returned error is fed back to the model and the output regenerated.

It MUST be side-effect-free: a later attempt re-runs every finalizer, so committing anything here would commit it several times. Committing the output happens after the turn, never in a finalizer.

meta is the run's Process metadata, which is what lets one registered finalizer serve every run: the host reads its own scope back out of it (the workspace, the case) instead of closing over a single run's values, since a strategy is registered once at startup and then serves every run of that agent.

type Input added in v0.3.0

type Input struct {
	// SystemPrompt is the host's base persona prompt. The planner prompt is
	// rendered around it.
	SystemPrompt string `json:"system_prompt"`
	// UserInput is the first user message.
	UserInput string `json:"user_input"`
	// LanguageLabel ("Japanese", "English", …) drives the user-facing-language
	// directive. Empty omits it.
	LanguageLabel string `json:"language_label,omitempty"`
	// KnownToolIDs is the toolset-id vocabulary the planner may assign to a task.
	// It is both the prompt's enumeration and the JSON schema's enum.
	KnownToolIDs []string `json:"known_tool_ids"`
	// TaskContext is an opaque block the host renders into EVERY sub-agent's
	// system prompt. planexec neither parses nor validates it.
	//
	// It exists because a sub-agent's prompt is otherwise built from the planner's
	// task text alone, while its tools are pinned to the run's subject: a task
	// handed the Slack read tools and told to "read the case thread" has no way to
	// know the channel id or thread ts, so it invents them and the call fails
	// (slack__get_messages then reports "requires both channel_id and ts"). The
	// host puts the identifiers its tools are pinned to here — see
	// .claude/rules/architecture.md § "Agent tool wiring": a prompt that names a
	// tool and the context that tool needs must ship together.
	TaskContext string `json:"task_context,omitempty"`

	AllowQuestion       bool `json:"allow_question,omitempty"`
	AllowDirect         bool `json:"allow_direct,omitempty"`
	AllowSubAgentWrites bool `json:"allow_sub_agent_writes,omitempty"`
	// SuspendOnQuestion keeps the run open across the human's reply instead of
	// ending the turn on it: the run parks on a question await and continues when
	// the host calls Kernel.Respond with the answer.
	//
	// A host should set it ONLY when its own record spans the wait — an
	// interactive Job, whose run id covers the whole exchange. For a Slack thread
	// it is the wrong trade: the run would hold the thread's subject for as long as
	// the person takes to answer, blocking every later turn on that thread.
	SuspendOnQuestion bool `json:"suspend_on_question,omitempty"`

	// Progress locates the thread milestone lines are drawn into. A zero value
	// draws nothing.
	Progress ProgressTarget `json:"progress,omitempty"`
}

Input is the launch input: what the host decided before the run existed. Everything the runtime supplies itself — history, traces, tools, the question channel — is deliberately absent.

func (Input) Validate added in v0.3.0

func (in Input) Validate() error

Validate enforces what the planner cannot run without.

type Output added in v0.3.0

type Output[T any] struct {
	Kind OutputKind `json:"kind"`
	// Data is the validated structured output. Set only for OutputFinal on a
	// structured host.
	Data *T `json:"data,omitempty"`
	// Text is the reply for OutputDirect, and for OutputFinal on a text-only host.
	Text string `json:"text,omitempty"`
	// Question is what to ask the user. Set only for OutputQuestion.
	Question *Question `json:"question,omitempty"`
	// FallbackReason says why no conclusion was reached.
	FallbackReason string `json:"fallback_reason,omitempty"`
	// Observations is the per-round trail, carried on every kind so even a
	// fallback can report what was learnt.
	Observations []PhaseSummary `json:"observations,omitempty"`
}

Output is what a finished run produces. Exactly one of Data / Text / Question carries the payload, selected by Kind.

func DecodeOutput added in v0.3.0

func DecodeOutput[T Validatable](raw []byte) (Output[T], error)

DecodeOutput reads back the bytes a finished Process stored. A host reading a completed run's output needs this; the kernel itself never calls it.

type OutputKind added in v0.3.0

type OutputKind string

OutputKind discriminates how a turn ended.

const (
	// OutputFinal is a planner-declared finalize that produced a terminal output.
	OutputFinal OutputKind = "final"
	// OutputDirect is the round-1 answer-without-investigation path.
	OutputDirect OutputKind = "direct"
	// OutputQuestion is the planner asking the user and ending the turn.
	OutputQuestion OutputKind = "question"
	// OutputFallback is a turn that reached no conclusion.
	OutputFallback OutputKind = "fallback"
)

type PhaseSummary

type PhaseSummary struct {
	Phase   int
	Tasks   []TaskPlan
	Results []TaskResult
}

PhaseSummary aggregates one round's task results so the planner has structured observations on the next round.

type PlanResult

type PlanResult struct {
	// Message is a 1-2 sentence rationale for the decision. No code reads it, and
	// that is not a reason to remove it: the planner reply carrying it is committed
	// to the run's conversation and recorded as that transition's LLM_RESPONSE, so
	// it is what the run timeline and the trace archive preserve of WHY the turn
	// decided as it did. Deleting the field would take that out of the record.
	//
	// It is NOT user-facing. The user sees the progress lines the strategy writes
	// and the terminal output, never this. (It once reached the user through
	// Sink.PlanProposed, removed with the in-process Runner in #261.) Do not wire it
	// into a reply — publishing the planner's reasoning as the answer is exactly
	// what rationaleDescription is worded to prevent.
	Message string `json:"message,omitempty"`
	// Tasks is the parallel investigation phase emitted by the planner.
	// Empty / omitted when Direct is set — the two are mutually exclusive.
	Tasks []TaskPlan `json:"tasks,omitempty"`
	// Direct, when non-nil, signals the planner judged the request trivial
	// enough to answer without any investigation phase. The nil Direct (the
	// common case) means "investigate via Tasks". Mutually exclusive with
	// Tasks; rejected unless RunRequest.AllowDirect is true.
	Direct *DirectPlan `json:"direct,omitempty"`
}

PlanResult is the parsed shape of the first planner-round JSON output. The planner must choose exactly one of two shapes:

  • Tasks: the parallel investigation phase (the default path). At least one task is required; if the host wants the planner to terminate after a phase, that is a replan-round concern.
  • Direct: skip investigation entirely and answer the user directly (round-1 fast path). Only valid when the host set RunRequest.AllowDirect.

type Progress added in v0.3.0

type Progress interface {
	// Render draws lines as one message and returns its id. An empty messageTS
	// means "post a new one"; anything else means "update that one".
	Render(ctx context.Context, target ProgressTarget, messageTS string, lines []string) (string, error)
}

Progress draws a run's milestone lines. The host implements it; planexec holds no Slack dependency.

It is stateless on purpose: the message id and the lines so far live in the run's checkpointed state, so another instance picking the run up keeps drawing into the same message instead of starting a second one.

type ProgressTarget added in v0.3.0

type ProgressTarget struct {
	ChannelID string `json:"channel_id,omitempty"`
	ThreadTS  string `json:"thread_ts,omitempty"`
}

ProgressTarget is the thread a run draws its milestones into.

type Question

type Question struct {
	// Reason is the rationale shared across every item ("why am I
	// asking?").
	Reason string `json:"reason"`
	// Items is the ordered list of questions to ask (1..5 items,
	// enforced by Validate).
	Items []QuestionItem `json:"items"`
}

Question is the host-facing payload when the planner needs human input. proposal forwards it to the Slack question UI; the job host has AllowQuestion=false and therefore never sees one.

func (*Question) Validate

func (q *Question) Validate() error

Validate enforces Question invariants. Called from Validate on the containing ReplanResult.

type QuestionAnswer

type QuestionAnswer struct {
	ID       string   `json:"id"`
	Choice   string   `json:"choice,omitempty"`    // select
	Choices  []string `json:"choices,omitempty"`   // multi_select
	FreeText string   `json:"free_text,omitempty"` // free_text
}

QuestionAnswer is the host's reply payload for one QuestionItem.

type QuestionItem

type QuestionItem struct {
	ID      string           `json:"id"`
	Text    string           `json:"text"`
	Type    QuestionItemType `json:"type"`
	Options []string         `json:"options,omitempty"`
}

QuestionItem is one question within Question.Items.

func (*QuestionItem) Validate

func (i *QuestionItem) Validate() error

Validate enforces QuestionItem invariants. The free_text exemption applies: free_text items skip the Options ≥2 rule and ignore any supplied options as a discardable hint.

type QuestionItemType

type QuestionItemType string

QuestionItemType discriminates how the host should render the answer control. Closed-list types (select / multi_select) require non-empty Options; free_text is the last-resort prose-input shape and Options is ignored. The planner is told to prefer the closed-list types — see prompts/planner.md for the policy.

const (
	QuestionItemSelect      QuestionItemType = "select"
	QuestionItemMultiSelect QuestionItemType = "multi_select"
	QuestionItemFreeText    QuestionItemType = "free_text"
)

type QuestionResult

type QuestionResult struct {
	// Terminate, when true, signals planexec.Runner to stop the loop
	// immediately and return RunStatus=Completed without invoking the
	// final-response phase. Used by proposal to defer the conversation
	// to the next thread reply.
	Terminate bool
	// Items, when Terminate=false, supplies the user's answers and the
	// loop continues with these injected into the next planner round.
	// Empty when Terminate=true.
	Items []QuestionAnswer
}

QuestionResult is what the host returns from OnQuestion. The hecatoncheires proposal host uses the {Terminate=true} shape (the session ends after the planner asks) but the type is set up to also support warren-style in-loop continuation in the future.

type ReplanResult

type ReplanResult struct {
	// Message is the replan round's counterpart of PlanResult.Message, kept for the
	// same reason and carrying the same warning: no code reads it, it survives in
	// the run's record, and it is not user-facing.
	Message  string        `json:"message,omitempty"`
	Tasks    []TaskPlan    `json:"tasks,omitempty"`
	Question *Question     `json:"question,omitempty"`
	Finalize *FinalizePlan `json:"finalize,omitempty"`
}

ReplanResult is the parsed shape of every subsequent planner round. The planner must choose EXACTLY ONE terminal-or-continuation action per round:

  • Tasks: run another investigation phase.
  • Question: ask the user (only when the host allows it).
  • Finalize: declare completion and produce the final output.

An output that sets none of the three is rejected (parseReplanResult) and folded back into another replan round. This is deliberate: the previous design treated "empty tasks + no question" as an implicit completion signal, so a planner that merely forgot to emit tasks would silently terminate (and, in structured hosts, commit) a half-finished turn. Completion is now an explicit act.

type TaskPlan

type TaskPlan struct {
	ID                 string   `json:"id"`
	Title              string   `json:"title"`
	Description        string   `json:"description"`
	AcceptanceCriteria string   `json:"acceptance_criteria"`
	Tools              []string `json:"tools"`
	// BudgetUSD is what this task's sub-agent may spend, in USD, carved by the
	// planner out of what the run has left. It is the planner's job because the
	// planner is the only party that knows which of the tasks it just wrote is the
	// heavy one.
	//
	// It is omitempty and validated only when the host wired Config.Remaining: a
	// host that did not is not asking for per-task budgets, and its children keep
	// inheriting the run's own figure.
	BudgetUSD float64 `json:"budget_usd,omitempty"`
}

TaskPlan is one parallel investigation task within a PlanResult or ReplanResult:

  • ID: stable identifier the host uses to correlate progress lines
  • Title: short label rendered to the user
  • Description: full instruction handed to the sub-agent
  • AcceptanceCriteria: the measurable bar against which the next replan judges whether the goal has been met
  • Tools: the toolset ids this task's sub-agent may use (a subset of Input.KnownToolIDs)

func (*TaskPlan) Validate

func (t *TaskPlan) Validate(knownToolIDs []string) error

Validate enforces TaskPlan invariants. KnownToolIDs is the host-supplied allowlist (RunRequest.KnownToolIDs); every entry in TaskPlan.Tools must be a member. Validate checks one task's own shape. The per-task budget is NOT checked here: its rules are stated against the round's remaining allowance, so they live in validateTaskList where that figure is in hand.

type TaskResult

type TaskResult struct {
	TaskID             string
	Title              string
	AcceptanceCriteria string
	Status             TaskStatus
	Summary            string
	Error              string
}

TaskResult is the per-task summary folded into the next planner round's observations.

type TaskStatus

type TaskStatus string

TaskStatus marks the outcome of a single sub-agent task.

const (
	TaskStatusCompleted TaskStatus = "completed"
	TaskStatusFailed    TaskStatus = "failed"
)

type TextResult added in v0.3.0

type TextResult struct {
	Text string `json:"text"`
}

TextResult is the T for hosts whose terminal output is prose rather than a structured object. Validate always accepts: there is no shape to check.

func (TextResult) Validate added in v0.3.0

func (TextResult) Validate() error

Validate satisfies Validatable.

type Validatable

type Validatable interface {
	Validate() error
}

Validatable is the constraint on the structured terminal-output type. The strategy decodes the planner's terminal JSON into T, then calls Validate() and regenerates on failure: gollem's response-schema check verifies the JSON shape only, so Validate() is where a host enforces its domain invariants (required fields, allowed values). A TextOnly host instantiates T as TextResult, whose Validate always accepts.

Jump to

Keyboard shortcuts

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