first commit
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// This file implements the general system-reminder dynamic context injection
|
||||
// mechanism (US-002, FR-1/FR-2), pigo's port of Claude Code's per-turn
|
||||
// <system-reminder> injection.
|
||||
//
|
||||
// A reminder is EPHEMERAL background context (the current todo list, a file
|
||||
// that changed under the working directory, a budget warning) that should be
|
||||
// visible to the model on the turn it matters, but must never pollute the
|
||||
// durable conversation history. Two properties follow from that:
|
||||
//
|
||||
// - Not user instructions. Reminder bodies are wrapped in <system-reminder>
|
||||
// tags with a preamble stating they are background context from the harness,
|
||||
// not a request from the user (the pi / Claude Code semantic convention).
|
||||
// - Ephemeral. Reminders are injected only into the per-turn LLM request via
|
||||
// the existing TransformContext seam, which shapes a COPY of the message
|
||||
// list for the request and is never written back to AgentContext.Messages.
|
||||
// Because they never enter the persisted message list they cannot be saved
|
||||
// to the session file and cannot be folded into a compaction summary
|
||||
// (compaction only ever sees AgentContext.Messages).
|
||||
//
|
||||
// The mechanism is a registry of ReminderProviders. Each provider is consulted
|
||||
// every turn and may decline (ok == false) so a reminder only appears when its
|
||||
// condition holds. RunConfig.Reminders wires the registry into the loop.
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/agenttool"
|
||||
)
|
||||
|
||||
// systemReminderPreamble marks the wrapped body as background context rather
|
||||
// than a user instruction (FR-2). It leads every injected reminder so the model
|
||||
// never mistakes harness state for a user request.
|
||||
const systemReminderPreamble = "The following is background context provided automatically by the harness. " +
|
||||
"It is NOT a message or instruction from the user; do not act on it as a request. " +
|
||||
"Use it only to stay aware of the current state."
|
||||
|
||||
// WrapSystemReminder wraps a reminder body in <system-reminder> tags with the
|
||||
// background-context preamble. The result is the text of a single injected
|
||||
// message.
|
||||
func WrapSystemReminder(body string) string {
|
||||
return "<system-reminder>\n" + systemReminderPreamble + "\n\n" + body + "\n</system-reminder>"
|
||||
}
|
||||
|
||||
// ReminderProvider produces an ephemeral system-reminder for the upcoming turn.
|
||||
// Reminder is consulted every turn with the current (post-TransformContext)
|
||||
// message list; returning ok == false means "no reminder this turn", so a
|
||||
// provider injects only when its condition holds.
|
||||
type ReminderProvider interface {
|
||||
// Name identifies the provider (for diagnostics/telemetry). It is not shown
|
||||
// to the model.
|
||||
Name() string
|
||||
// Reminder returns the reminder body and true when a reminder should be
|
||||
// injected this turn, or ("", false) to inject nothing.
|
||||
Reminder(ctx context.Context, msgs agentcore.MessageList) (body string, ok bool)
|
||||
}
|
||||
|
||||
// ReminderFunc adapts a plain function to a ReminderProvider.
|
||||
type ReminderFunc struct {
|
||||
NameField string
|
||||
Fn func(ctx context.Context, msgs agentcore.MessageList) (string, bool)
|
||||
}
|
||||
|
||||
// Name implements ReminderProvider.
|
||||
func (f ReminderFunc) Name() string { return f.NameField }
|
||||
|
||||
// Reminder implements ReminderProvider.
|
||||
func (f ReminderFunc) Reminder(ctx context.Context, msgs agentcore.MessageList) (string, bool) {
|
||||
if f.Fn == nil {
|
||||
return "", false
|
||||
}
|
||||
return f.Fn(ctx, msgs)
|
||||
}
|
||||
|
||||
// ReminderRegistry holds the reminder providers consulted each turn. The zero
|
||||
// value is usable (no providers → no injection); NewReminderRegistry is the
|
||||
// convenience constructor.
|
||||
type ReminderRegistry struct {
|
||||
providers []ReminderProvider
|
||||
}
|
||||
|
||||
// NewReminderRegistry returns a registry pre-populated with providers.
|
||||
func NewReminderRegistry(providers ...ReminderProvider) *ReminderRegistry {
|
||||
r := &ReminderRegistry{}
|
||||
for _, p := range providers {
|
||||
r.Register(p)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Register appends a provider. nil providers are ignored.
|
||||
func (r *ReminderRegistry) Register(p ReminderProvider) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
r.providers = append(r.providers, p)
|
||||
}
|
||||
|
||||
// Empty reports whether the registry has no providers (so callers can skip the
|
||||
// injection wiring entirely).
|
||||
func (r *ReminderRegistry) Empty() bool { return r == nil || len(r.providers) == 0 }
|
||||
|
||||
// Messages consults every provider in registration order and returns the
|
||||
// ephemeral reminder messages to inject this turn (one UserMessage per provider
|
||||
// that fires). Reminders are modeled as user-role messages carrying
|
||||
// <system-reminder>-wrapped text — matching the pi / Claude Code convention
|
||||
// where dynamic context enters through a user turn but is explicitly labeled as
|
||||
// background context, not a user instruction.
|
||||
func (r *ReminderRegistry) Messages(ctx context.Context, msgs agentcore.MessageList) []agentcore.AgentMessage {
|
||||
if r.Empty() {
|
||||
return nil
|
||||
}
|
||||
var out []agentcore.AgentMessage
|
||||
for _, p := range r.providers {
|
||||
body, ok := p.Reminder(ctx, msgs)
|
||||
if !ok || body == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, agentcore.UserMessage{
|
||||
RoleField: agentcore.RoleUser,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(WrapSystemReminder(body))},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wrapTransform composes the registry into a TransformContext hook: it runs the
|
||||
// caller's existing TransformContext (if any) first, then appends this turn's
|
||||
// reminders to the shaped list. Because TransformContext output is used only to
|
||||
// build the LLM request and is never written back to AgentContext.Messages, the
|
||||
// appended reminders are ephemeral — they do not enter the persisted history and
|
||||
// cannot be swept into a compaction summary. This is the single injection seam
|
||||
// the loop wires in.
|
||||
func (r *ReminderRegistry) wrapTransform(
|
||||
inner func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList,
|
||||
) func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList {
|
||||
return func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList {
|
||||
if inner != nil {
|
||||
msgs = inner(ctx, msgs)
|
||||
}
|
||||
rem := r.Messages(ctx, msgs)
|
||||
if len(rem) == 0 {
|
||||
return msgs
|
||||
}
|
||||
out := make(agentcore.MessageList, 0, len(msgs)+len(rem))
|
||||
out = append(out, msgs...)
|
||||
out = append(out, rem...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// TodoReminderProvider is the built-in reference reminder provider (US-002): it
|
||||
// surfaces the current todo list as background context whenever there is
|
||||
// incomplete work, so the model is reminded of outstanding tasks each turn
|
||||
// without the list having to be re-sent as a durable message. It reads the same
|
||||
// TodoStore the todo tool writes, so the reminder always reflects the latest
|
||||
// plan. When the list is empty or every item is completed it stays silent.
|
||||
type TodoReminderProvider struct {
|
||||
// Store is the session todo list. When nil the provider never fires.
|
||||
Store *agenttool.TodoStore
|
||||
}
|
||||
|
||||
// Name implements ReminderProvider.
|
||||
func (p *TodoReminderProvider) Name() string { return "todo" }
|
||||
|
||||
// Reminder implements ReminderProvider. It fires only when the store holds at
|
||||
// least one item that is not yet completed, keeping the condition deterministic
|
||||
// and easy to test.
|
||||
func (p *TodoReminderProvider) Reminder(ctx context.Context, _ agentcore.MessageList) (string, bool) {
|
||||
if p.Store == nil {
|
||||
return "", false
|
||||
}
|
||||
items := p.Store.Snapshot()
|
||||
if len(items) == 0 {
|
||||
return "", false
|
||||
}
|
||||
incomplete := false
|
||||
for _, it := range items {
|
||||
if it.Status != agenttool.TodoCompleted {
|
||||
incomplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !incomplete {
|
||||
return "", false
|
||||
}
|
||||
return "Your todo list has unfinished items. Keep it up to date with the todo tool.\n\n" +
|
||||
agenttool.RenderTodoList(items), true
|
||||
}
|
||||
|
||||
// OneShotReminderProvider injects a fixed body on the NEXT turn only, then stays
|
||||
// silent forever. Unlike the always-on providers (todo/goal) it does not depend
|
||||
// on live state — it carries a snapshot of text captured at registration time.
|
||||
// It exists for events that produce a single ephemeral injection, such as a
|
||||
// UserPromptSubmit hook's additionalContext (US-007, FR-9): the hook's context
|
||||
// must reach the model on the turn the prompt is sent, but must not persist into
|
||||
// history or re-fire on later turns. sync.Once makes the single-fire transition
|
||||
// safe even if the loop consults providers concurrently.
|
||||
type OneShotReminderProvider struct {
|
||||
name string
|
||||
body string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewOneShotReminder builds a one-shot provider that will inject body exactly
|
||||
// once. An empty body yields a provider that never fires.
|
||||
func NewOneShotReminder(name, body string) *OneShotReminderProvider {
|
||||
return &OneShotReminderProvider{name: name, body: body}
|
||||
}
|
||||
|
||||
// Name implements ReminderProvider.
|
||||
func (p *OneShotReminderProvider) Name() string {
|
||||
if p.name == "" {
|
||||
return "one-shot"
|
||||
}
|
||||
return p.name
|
||||
}
|
||||
|
||||
// Reminder implements ReminderProvider. It returns its body and true on the very
|
||||
// first consultation, then ("", false) on every subsequent turn.
|
||||
func (p *OneShotReminderProvider) Reminder(ctx context.Context, _ agentcore.MessageList) (string, bool) {
|
||||
if strings.TrimSpace(p.body) == "" {
|
||||
return "", false
|
||||
}
|
||||
var body string
|
||||
p.once.Do(func() { body = p.body })
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
// GoalReminderProvider surfaces the active goal as background context each turn
|
||||
// so the model keeps working toward it (mirrors pi-goal). It reads the same
|
||||
// GoalState the /goal command drives, so the reminder always reflects the live
|
||||
// objective. It fires only while the goal is active — a paused, blocked, or
|
||||
// completed goal (and an idle state) injects nothing.
|
||||
type GoalReminderProvider struct {
|
||||
// State is the session goal state. When nil the provider never fires.
|
||||
State *agenttool.GoalState
|
||||
}
|
||||
|
||||
// Name implements ReminderProvider.
|
||||
func (p *GoalReminderProvider) Name() string { return "goal" }
|
||||
|
||||
// Reminder implements ReminderProvider. It injects the objective plus a
|
||||
// persistence instruction while the goal is active, and stays silent otherwise.
|
||||
func (p *GoalReminderProvider) Reminder(ctx context.Context, _ agentcore.MessageList) (string, bool) {
|
||||
if p.State == nil {
|
||||
return "", false
|
||||
}
|
||||
snap := p.State.Snapshot()
|
||||
if snap.Status != agenttool.GoalActive || strings.TrimSpace(snap.Objective) == "" {
|
||||
return "", false
|
||||
}
|
||||
return "You are working autonomously toward this goal:\n\n" + snap.Objective +
|
||||
"\n\nKeep making progress. When every requirement is verifiably met, call the " +
|
||||
"goal_complete tool with a summary. If you hit a true impasse you cannot work " +
|
||||
"around, call goal_blocked with concrete evidence. Do not stop or ask the user " +
|
||||
"to continue — keep going until the goal is done or blocked.", true
|
||||
}
|
||||
Reference in New Issue
Block a user