first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
// This file implements shell-style argument tokenization for prompt templates
// (US-001, #331). A template invocation like
//
// /component Button "click handler"
//
// arrives at the expander as the raw string `Button "click handler"`; the
// expander needs positional args ($1, $@, ...), so the string must be split into
// ["Button", "click handler"] honoring shell quoting rules: double and single
// quotes group a single argument, surrounding quotes are stripped, and internal
// whitespace is preserved.
//
// Rather than hand-roll a tokenizer, we reuse a mature shell-quoting library
// (github.com/kballard/go-shellquote), per the project's "reuse rather than reinvent" rule.
package runtime
import "github.com/kballard/go-shellquote"
// SplitArgs tokenizes a raw argument string using shell-style quoting rules.
// Double quotes ("a b") and single quotes ('a b') each group one argument;
// surrounding quotes are stripped and internal whitespace is preserved. An empty
// (or all-whitespace) input yields an empty non-nil slice with no error. An
// unterminated quote yields an error, so the caller can fall back to treating
// the whole string as $ARGUMENTS rather than feeding a malformed arg list to the
// template engine.
func SplitArgs(s string) ([]string, error) {
parts, err := shellquote.Split(s)
if err != nil {
return nil, err
}
if parts == nil {
return []string{}, nil
}
return parts, nil
}
+69
View File
@@ -0,0 +1,69 @@
package runtime
// Tests for shell-style argument tokenization (US-001, #331). Covers the cases
// called out in the acceptance criteria: empty input, a single bare argument,
// double-quoted argument with internal space, single-quoted argument, mixed
// quoting, an unterminated quote (error), and leading/trailing whitespace.
import "testing"
func TestSplitArgs(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{"empty", "", []string{}},
{"only whitespace", " ", []string{}},
{"single bare arg", "Button", []string{"Button"}},
{"two bare args", "a b", []string{"a", "b"}},
{"double quoted preserves internal space", `Button "click handler"`, []string{"Button", "click handler"}},
{"single quoted preserves internal space", `'a b'`, []string{"a b"}},
{"mixed single and double quotes", `x "y z" 'p q' r`, []string{"x", "y z", "p q", "r"}},
{"leading and trailing whitespace trimmed", ` hello world `, []string{"hello", "world"}},
{"quoted arg at boundaries", `"a b" c "d e"`, []string{"a b", "c", "d e"}},
{"empty double quotes yield empty arg", `""`, []string{""}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := SplitArgs(c.in)
if err != nil {
t.Fatalf("SplitArgs(%q) returned unexpected error: %v", c.in, err)
}
if len(got) != len(c.want) {
t.Fatalf("SplitArgs(%q) = %v (len %d), want %v (len %d)", c.in, got, len(got), c.want, len(c.want))
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("SplitArgs(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
}
}
})
}
}
func TestSplitArgsEmptyReturnsNonNil(t *testing.T) {
got, err := SplitArgs("")
if err != nil {
t.Fatalf("SplitArgs(\"\") returned unexpected error: %v", err)
}
if got == nil {
t.Fatal("SplitArgs(\"\") returned nil slice, want non-nil empty slice")
}
if len(got) != 0 {
t.Fatalf("SplitArgs(\"\") = %v, want empty", got)
}
}
func TestSplitArgsUnclosedQuoteErrors(t *testing.T) {
cases := []string{
`"unterminated`,
`'unterminated`,
`foo "bar baz`,
}
for _, in := range cases {
if _, err := SplitArgs(in); err == nil {
t.Errorf("SplitArgs(%q) expected an error for unterminated quote, got nil", in)
}
}
}
+193
View File
@@ -0,0 +1,193 @@
// Checkpoint persistence for the "infinite context" feature (#480). A checkpoint
// is a distilled summary of a conversation *prefix* — everything up to a
// watermark message index — persisted as a Markdown memory file so a later run
// can reload the collapsed context instead of replaying (and re-tokenizing) the
// whole transcript.
//
// The file lives at <memoryRoot>/sessions/<sessionID>/checkpoint.md and carries
// the repo's standard YAML frontmatter (name/description/metadata.type) with the
// checkpoint bookkeeping (watermark, createdAt, covered message count) under
// metadata; the Summary is the Markdown body. This mirrors the memory-file
// convention (see internal/memory, TypeCheckpoint = "checkpoint") so the memory
// indexer can pick these files up unchanged.
//
// This node provides only the persistence primitives plus the summarize→
// Checkpoint bridge. Wiring into the run loop (#481) is deliberately out of
// scope: a checkpoint write failure is returned to the caller, which is expected
// to log-and-continue rather than abort the turn.
package runtime
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"gopkg.in/yaml.v3"
)
// Checkpoint is a distilled summary of the conversation up to (and including)
// Watermark. It is the in-memory form written to / read from checkpoint.md.
type Checkpoint struct {
// Watermark is the message index the checkpoint summarizes up to: messages
// [0, Watermark) are collapsed into Summary. A resumed run replays only the
// tail after Watermark, prepending Summary as the collapsed context.
Watermark int
// Summary is the distilled context (the Markdown body of checkpoint.md),
// produced by the summarization LLM call (see compaction.GenerateSummary).
Summary string
// CreatedAt is when the checkpoint was distilled (RFC 3339, UTC).
CreatedAt time.Time
// CoveredMessages is how many messages the summary actually folded in. It
// usually equals Watermark for a linear prefix but is recorded separately so
// a caller that checkpoints a non-contiguous slice still keeps an honest count.
CoveredMessages int
}
// SummarizeFunc distills a slice of conversation messages into a single summary
// string. It is the seam that lets BuildCheckpoint reuse compaction.GenerateSummary
// without this package depending on the provider stack: the caller supplies a
// closure over GenerateSummary (binding ctx/stream/model/cfg), and BuildCheckpoint
// invokes it. A summarization error is propagated, never swallowed.
type SummarizeFunc func(ctx context.Context, msgs []agentcore.Message) (string, error)
// checkpointFrontmatter is the YAML head of checkpoint.md. It matches the repo's
// name/description/metadata convention (mirrors SkillFrontmatter and the memory
// file layout) so the file is a well-formed memory document.
type checkpointFrontmatter struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Metadata checkpointMetadata `yaml:"metadata"`
}
// checkpointMetadata carries the checkpoint bookkeeping under metadata. Type is
// fixed to "checkpoint" (memory.TypeCheckpoint) so the memory indexer classifies
// it correctly.
type checkpointMetadata struct {
Type string `yaml:"type"`
Watermark int `yaml:"watermark"`
CreatedAt time.Time `yaml:"createdAt"`
CoveredMessages int `yaml:"coveredMessages"`
}
// checkpointType is the metadata.type value for a checkpoint memory file. It is
// duplicated here (rather than importing internal/memory) to keep this package's
// dependency surface minimal; the two must stay in sync.
const checkpointType = "checkpoint"
// CheckpointPath returns the on-disk path of a session's checkpoint file:
// <memoryRoot>/sessions/<sessionID>/checkpoint.md.
func CheckpointPath(sessionID, memoryRoot string) string {
return filepath.Join(memoryRoot, "sessions", sessionID, "checkpoint.md")
}
// BuildCheckpoint distills msgs into a Checkpoint by invoking summarize, tagging
// the result with watermark and now (coerced to UTC). It performs no I/O — the
// caller persists the result with WriteCheckpoint — so the (potentially slow,
// potentially failing) summarization call stays off the write path. A nil
// summarize or a summarization error is returned as an error.
func BuildCheckpoint(ctx context.Context, msgs []agentcore.Message, watermark int, now time.Time, summarize SummarizeFunc) (Checkpoint, error) {
if summarize == nil {
return Checkpoint{}, fmt.Errorf("runtime: BuildCheckpoint: nil summarize func")
}
summary, err := summarize(ctx, msgs)
if err != nil {
return Checkpoint{}, fmt.Errorf("runtime: distill checkpoint: %w", err)
}
return Checkpoint{
Watermark: watermark,
Summary: summary,
CreatedAt: now.UTC(),
CoveredMessages: len(msgs),
}, nil
}
// WriteCheckpoint persists cp as <memoryRoot>/sessions/<sessionID>/checkpoint.md,
// creating parent directories (0o755). It writes to a temp file and atomically
// renames it into place so a concurrent reader never sees a half-written file.
// The write is intended to be non-fatal to callers: on error the on-disk file is
// left untouched and the error is returned for the caller to log-and-continue.
func WriteCheckpoint(sessionID, memoryRoot string, cp Checkpoint) error {
if sessionID == "" {
return fmt.Errorf("runtime: WriteCheckpoint: empty sessionID")
}
path := CheckpointPath(sessionID, memoryRoot)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("runtime: create checkpoint dir: %w", err)
}
doc, err := renderCheckpoint(cp)
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, doc, 0o644); err != nil {
return fmt.Errorf("runtime: write checkpoint temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("runtime: commit checkpoint: %w", err)
}
return nil
}
// renderCheckpoint serializes cp into the checkpoint.md byte form: a "---"-fenced
// YAML frontmatter block followed by the Summary as the Markdown body.
func renderCheckpoint(cp Checkpoint) ([]byte, error) {
fm := checkpointFrontmatter{
Name: "checkpoint",
Description: fmt.Sprintf("Conversation checkpoint at watermark %d (%d messages).", cp.Watermark, cp.CoveredMessages),
Metadata: checkpointMetadata{
Type: checkpointType,
Watermark: cp.Watermark,
CreatedAt: cp.CreatedAt.UTC(),
CoveredMessages: cp.CoveredMessages,
},
}
fmBytes, err := yaml.Marshal(fm)
if err != nil {
return nil, fmt.Errorf("runtime: encode checkpoint frontmatter: %w", err)
}
var b bytes.Buffer
b.WriteString("---\n")
b.Write(fmBytes)
b.WriteString("---\n\n")
b.WriteString(cp.Summary)
return b.Bytes(), nil
}
// LoadCheckpoint reads and parses the checkpoint for sessionID under memoryRoot.
// It returns (nil, false, nil) when the file does not exist — a missing
// checkpoint is a normal "no collapsed context yet" state, not an error. A
// present-but-malformed file yields a non-nil error.
func LoadCheckpoint(sessionID, memoryRoot string) (*Checkpoint, bool, error) {
path := CheckpointPath(sessionID, memoryRoot)
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, fmt.Errorf("runtime: read checkpoint: %w", err)
}
fmBytes, body, err := splitFrontmatter(content)
if err != nil {
return nil, false, fmt.Errorf("runtime: parse checkpoint %s: %w", path, err)
}
var fm checkpointFrontmatter
if err := yaml.Unmarshal(fmBytes, &fm); err != nil {
return nil, false, fmt.Errorf("runtime: decode checkpoint frontmatter %s: %w", path, err)
}
cp := &Checkpoint{
Watermark: fm.Metadata.Watermark,
Summary: string(bytes.TrimLeft(body, "\r\n")),
CreatedAt: fm.Metadata.CreatedAt.UTC(),
CoveredMessages: fm.Metadata.CoveredMessages,
}
return cp, true, nil
}
+138
View File
@@ -0,0 +1,138 @@
package runtime
// Tests for checkpoint persistence (#480): the write→load round-trip, the
// missing-file sentinel, and the summarize→Checkpoint bridge. They drive the
// real filesystem via t.TempDir(), matching the session/memory test style.
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
// TestCheckpointRoundTrip is the core acceptance check: a written checkpoint
// loads back with watermark, summary, createdAt, and covered count preserved.
func TestCheckpointRoundTrip(t *testing.T) {
root := t.TempDir()
created := time.Date(2026, 8, 1, 9, 30, 0, 0, time.UTC)
cp := Checkpoint{
Watermark: 42,
Summary: "The user asked to refactor foo().\nWe extracted a helper and added tests.",
CreatedAt: created,
CoveredMessages: 42,
}
if err := WriteCheckpoint("sess-abc", root, cp); err != nil {
t.Fatalf("WriteCheckpoint: %v", err)
}
got, ok, err := LoadCheckpoint("sess-abc", root)
if err != nil {
t.Fatalf("LoadCheckpoint: %v", err)
}
if !ok {
t.Fatal("LoadCheckpoint: found = false, want true")
}
if got.Watermark != cp.Watermark {
t.Errorf("Watermark = %d, want %d", got.Watermark, cp.Watermark)
}
if got.CoveredMessages != cp.CoveredMessages {
t.Errorf("CoveredMessages = %d, want %d", got.CoveredMessages, cp.CoveredMessages)
}
if got.Summary != cp.Summary {
t.Errorf("Summary = %q, want %q", got.Summary, cp.Summary)
}
if !got.CreatedAt.Equal(cp.CreatedAt) {
t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, cp.CreatedAt)
}
}
// TestCheckpointFileLayoutAndFrontmatter verifies the file lands at the expected
// path and carries the repo's name/description/metadata.type=checkpoint convention.
func TestCheckpointFileLayoutAndFrontmatter(t *testing.T) {
root := t.TempDir()
cp := Checkpoint{Watermark: 3, Summary: "body text", CreatedAt: time.Now().UTC(), CoveredMessages: 3}
if err := WriteCheckpoint("s1", root, cp); err != nil {
t.Fatalf("WriteCheckpoint: %v", err)
}
want := filepath.Join(root, "sessions", "s1", "checkpoint.md")
if want != CheckpointPath("s1", root) {
t.Fatalf("CheckpointPath = %q, want %q", CheckpointPath("s1", root), want)
}
content, err := os.ReadFile(want)
if err != nil {
t.Fatalf("read checkpoint file: %v", err)
}
fm, _, err := splitFrontmatter(content)
if err != nil {
t.Fatalf("splitFrontmatter: %v", err)
}
s := string(fm)
for _, needle := range []string{"name: checkpoint", "type: checkpoint", "watermark: 3"} {
if !strings.Contains(s, needle) {
t.Errorf("frontmatter missing %q; got:\n%s", needle, s)
}
}
}
// TestLoadCheckpointMissing verifies a missing checkpoint is the (nil,false,nil)
// sentinel, not an error — callers treat "no checkpoint yet" as normal.
func TestLoadCheckpointMissing(t *testing.T) {
root := t.TempDir()
cp, ok, err := LoadCheckpoint("nope", root)
if err != nil {
t.Fatalf("LoadCheckpoint on missing file: err = %v, want nil", err)
}
if ok {
t.Error("found = true, want false")
}
if cp != nil {
t.Errorf("checkpoint = %+v, want nil", cp)
}
}
// TestBuildCheckpoint verifies the summarize bridge tags the result with the
// watermark, covered count, and a UTC createdAt.
func TestBuildCheckpoint(t *testing.T) {
msgs := []agentcore.Message{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("hello")}},
}
now := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("x", 3600))
summarize := func(ctx context.Context, m []agentcore.Message) (string, error) {
if len(m) != len(msgs) {
t.Errorf("summarize got %d msgs, want %d", len(m), len(msgs))
}
return "distilled", nil
}
cp, err := BuildCheckpoint(context.Background(), msgs, 2, now, summarize)
if err != nil {
t.Fatalf("BuildCheckpoint: %v", err)
}
if cp.Summary != "distilled" {
t.Errorf("Summary = %q, want %q", cp.Summary, "distilled")
}
if cp.Watermark != 2 || cp.CoveredMessages != 2 {
t.Errorf("Watermark/Covered = %d/%d, want 2/2", cp.Watermark, cp.CoveredMessages)
}
if cp.CreatedAt.Location() != time.UTC {
t.Errorf("CreatedAt not UTC: %v", cp.CreatedAt)
}
}
// TestBuildCheckpointPropagatesError verifies a summarization failure surfaces
// as an error rather than a partial checkpoint (write path never runs).
func TestBuildCheckpointPropagatesError(t *testing.T) {
boom := errors.New("summarize failed")
_, err := BuildCheckpoint(context.Background(), nil, 0, time.Now(), func(context.Context, []agentcore.Message) (string, error) {
return "", boom
})
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want wrapping %v", err, boom)
}
}
+206
View File
@@ -0,0 +1,206 @@
package runtime
// Tests for auto-compaction wiring into the loop (US-004, #120): when context
// usage exceeds the usable window after a turn settles, runLoop compacts in
// place and emits a CompactionEvent; a compaction failure is non-fatal and is
// reported via a CompactionEvent carrying an error while the original context is
// preserved.
import (
"context"
"errors"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/provider"
)
// bigUserMessages returns n user messages each carrying `chars` characters, used
// to inflate estimated context tokens past a small window.
func bigUserMessages(n, chars int) agentcore.MessageList {
body := strings.Repeat("x", chars)
msgs := make(agentcore.MessageList, 0, n)
for i := 0; i < n; i++ {
msgs = append(msgs, agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(body)},
})
}
return msgs
}
// findCompaction returns the first CompactionEvent emitted, or nil.
func findCompaction(events []agentcore.AgentEvent) *agentcore.CompactionEvent {
for _, ev := range events {
if c, ok := ev.(agentcore.CompactionEvent); ok {
return &c
}
}
return nil
}
// collectEvents drains a stream returning the concrete events (not just kinds).
func collectEvents(t *testing.T, s *LoopEventStream) []agentcore.AgentEvent {
t.Helper()
var out []agentcore.AgentEvent
for ev := range s.Events() {
out = append(out, ev)
}
if _, err := s.Result(context.Background()); err != nil {
t.Fatalf("stream result: %v", err)
}
return out
}
// summaryStream yields a fixed summary text as an end_turn assistant message,
// standing in for the summarization model.
func summaryStream(text string) provider.StreamFn {
return func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
msg := agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
StopReason: agentcore.StopReasonEndTurn,
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
}
s := provider.NewAssistantMessageEventStream(0)
go func() { _ = s.Emit(ctx, provider.StreamDoneEvent{Message: msg}); s.Close() }()
return s, nil
}
}
func TestAutoCompactionFiresOnThreshold(t *testing.T) {
// Main stream just ends the turn with text; the summary stream is separate so
// the summarization does not consume main-stream scripted turns.
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
cfg := newRunCfg(main)
cfg.SummaryStream = summaryStream("## Goal\ncompacted")
// Small window + reserve so a handful of fat messages exceed the threshold.
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
// Seed a long history so EstimateContextTokens > window-reserve.
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(12, 800)}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
ce := findCompaction(events)
if ce == nil {
t.Fatalf("expected a CompactionEvent, got events %+v", events)
}
// A CompactionStartEvent must precede the CompactionEvent so a front-end can
// show an in-progress indicator while summarization is in flight.
var startedBefore bool
for _, ev := range events {
if _, ok := ev.(agentcore.CompactionStartEvent); ok {
startedBefore = true
}
if _, ok := ev.(agentcore.CompactionEvent); ok {
if !startedBefore {
t.Errorf("CompactionStartEvent must be emitted before CompactionEvent")
}
break
}
}
if !startedBefore {
t.Errorf("expected a CompactionStartEvent, got events %+v", events)
}
if ce.ErrorMessage != "" {
t.Fatalf("compaction should have succeeded, got error %q", ce.ErrorMessage)
}
if ce.TokensAfter >= ce.TokensBefore {
t.Errorf("compaction should reduce tokens: before=%d after=%d", ce.TokensBefore, ce.TokensAfter)
}
if ce.SummarizedCount <= 0 {
t.Errorf("expected some messages summarized, got %d", ce.SummarizedCount)
}
// The context must now begin with a compaction checkpoint.
if len(agentCtx.Messages) == 0 || agentCtx.Messages[0].Role() != agentcore.RoleCompaction {
t.Errorf("context should start with a compaction checkpoint, got %+v", agentCtx.Messages)
}
}
func TestAutoCompactionDisabledWhenWindowUnknown(t *testing.T) {
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
cfg := newRunCfg(main)
cfg.SummaryStream = summaryStream("unused")
cfg.ContextWindow = 0 // unknown → disabled
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(12, 800)}
before := len(agentCtx.Messages)
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
if ce := findCompaction(events); ce != nil {
t.Fatalf("no compaction expected when window unknown, got %+v", ce)
}
// Context grows by one assistant reply only (no checkpoint replacement).
if len(agentCtx.Messages) != before+1 {
t.Errorf("context should be untouched by compaction, got %d messages", len(agentCtx.Messages))
}
}
func TestCompactionEventEnvelope(t *testing.T) {
env := eventEnvelope(agentcore.CompactionEvent{
Reason: "threshold",
TokensBefore: 1000,
TokensAfter: 400,
SummarizedCount: 8,
KeptCount: 3,
})
if env["type"] != agentcore.EventCompaction {
t.Errorf("type = %v, want %q", env["type"], agentcore.EventCompaction)
}
if env["tokensBefore"] != 1000 || env["tokensAfter"] != 400 {
t.Errorf("token fields wrong: %+v", env)
}
if env["summarizedCount"] != 8 || env["keptCount"] != 3 {
t.Errorf("count fields wrong: %+v", env)
}
if _, hasErr := env["error"]; hasErr {
t.Errorf("no error key expected on success: %+v", env)
}
// Failure envelope carries the error.
failEnv := eventEnvelope(agentcore.CompactionEvent{Reason: "threshold", ErrorMessage: "boom"})
if failEnv["error"] != "boom" {
t.Errorf("error key expected on failure, got %+v", failEnv)
}
}
func TestAutoCompactionFailureIsNonFatal(t *testing.T) {
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
cfg := newRunCfg(main)
// Summary stream that fails to build: forces compaction.Compact to error.
cfg.SummaryStream = func(ctx context.Context, model string, llm provider.LlmContext, c provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
return nil, errors.New("summarizer down")
}
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(12, 800)}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
ce := findCompaction(events)
if ce == nil {
t.Fatalf("expected a CompactionEvent reporting the failure")
}
if ce.ErrorMessage == "" {
t.Errorf("failed compaction must carry an ErrorMessage")
}
if ce.TokensAfter != ce.TokensBefore {
t.Errorf("on failure tokens must be unchanged: before=%d after=%d", ce.TokensBefore, ce.TokensAfter)
}
// The run must still end normally.
if events[len(events)-1].EventType() != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end despite compaction failure")
}
// No compaction checkpoint should have been inserted.
if len(agentCtx.Messages) > 0 && agentCtx.Messages[0].Role() == agentcore.RoleCompaction {
t.Errorf("failed compaction must not insert a checkpoint")
}
}
+186
View File
@@ -0,0 +1,186 @@
// This file implements the layered configuration system (US-023, #42), the
// pigo port of pi/zero's config resolution. A resolved Config is produced by
// merging partial layers in precedence order:
//
// default < global < project < environment/CLI
//
// Each layer is a *ConfigLayer whose fields are pointers, so "unset" (nil) is
// distinguishable from "set to the zero value" — only set fields override lower
// layers (field-level replacement, no deep merge). The final Config is
// validated: an unknown thinking level or tool-execution mode is a hard error,
// as is a malformed layer file.
package runtime
import (
"encoding/json"
"fmt"
"os"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
)
// Config is the fully resolved configuration a run operates under, after all
// layers are merged and validated.
type Config struct {
// Model is the default model id used when a run does not specify one.
Model string
// Provider is the default provider name.
Provider string
// Credentials maps provider name → API key. Merged per-provider across
// layers (a higher layer's key for provider X overrides a lower one, but
// providers only present in a lower layer are retained).
Credentials map[string]string
// ToolExecutionMode is the default execution mode for tools that do not pin
// their own mode.
ToolExecutionMode agentcore.ToolExecutionMode
// ThinkingLevel is the default reasoning-effort level.
ThinkingLevel agentcore.ThinkingLevel
// Hooks are the resolved, append-merged hook matchers keyed by event type.
// Nil when no layer defined any hooks (FR-18), so the no-hooks path costs
// nothing downstream.
Hooks hooks.HookSet
}
// ConfigLayer is one partial layer of configuration. Pointer/optional fields
// distinguish "not set in this layer" (nil/empty) from an explicit value, so a
// higher layer only overrides the fields it actually sets.
type ConfigLayer struct {
Model *string `json:"model,omitempty"`
Provider *string `json:"provider,omitempty"`
Credentials map[string]string `json:"credentials,omitempty"`
ToolExecutionMode *string `json:"toolExecutionMode,omitempty"`
ThinkingLevel *string `json:"thinkingLevel,omitempty"`
// Hooks are this layer's hook matchers keyed by event type. Unlike the
// scalar fields, hooks are not overridden across layers: ResolveConfig
// appends each layer's matchers per event type (FR-2), so lower-layer hooks
// always still fire.
Hooks hooks.HookSet `json:"hooks,omitempty"`
}
// DefaultConfigLayer is the base layer applied before all others. It gives a
// usable configuration out of the box.
func DefaultConfigLayer() ConfigLayer {
model := "openrouter/free"
provider := "openrouter"
mode := string(agentcore.ToolExecutionParallel)
level := string(agentcore.ThinkingMedium)
return ConfigLayer{
Model: &model,
Provider: &provider,
ToolExecutionMode: &mode,
ThinkingLevel: &level,
}
}
// LoadConfigLayer reads and decodes a single JSON config layer from path. A
// missing file yields a nil layer and no error (an absent layer is not a
// failure); a present-but-malformed file is a hard error.
func LoadConfigLayer(path string) (*ConfigLayer, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var layer ConfigLayer
if err := json.Unmarshal(data, &layer); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return &layer, nil
}
// ResolveConfig merges the given layers in ascending precedence order (earlier
// layers are overridden by later ones) and validates the result. Nil layers are
// skipped, so callers can pass the output of LoadConfigLayer directly. The
// merge is field-level: a later layer only overrides fields it sets, except
// Credentials, which merges per-provider.
func ResolveConfig(layers ...*ConfigLayer) (Config, error) {
var cfg Config
for _, layer := range layers {
if layer == nil {
continue
}
if layer.Model != nil {
cfg.Model = *layer.Model
}
if layer.Provider != nil {
cfg.Provider = *layer.Provider
}
if layer.ToolExecutionMode != nil {
cfg.ToolExecutionMode = agentcore.ToolExecutionMode(*layer.ToolExecutionMode)
}
if layer.ThinkingLevel != nil {
cfg.ThinkingLevel = agentcore.ThinkingLevel(*layer.ThinkingLevel)
}
for provider, key := range layer.Credentials {
if cfg.Credentials == nil {
cfg.Credentials = make(map[string]string)
}
cfg.Credentials[provider] = key
}
for eventType, matchers := range layer.Hooks {
if len(matchers) == 0 {
continue
}
if cfg.Hooks == nil {
cfg.Hooks = make(hooks.HookSet)
}
cfg.Hooks[eventType] = append(cfg.Hooks[eventType], matchers...)
}
}
if err := cfg.validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// EnvConfigLayer builds a config layer from environment variables, the highest
// file-independent layer (below only explicit CLI flags). Recognized:
//
// PIGO_MODEL, PIGO_PROVIDER, PIGO_TOOL_EXECUTION_MODE, PIGO_THINKING_LEVEL
//
// Only set variables contribute; unset ones leave the field nil so lower layers
// show through. Credential env vars are intentionally NOT captured here — keys
// are resolved lazily by the CredentialStore and never merged into a struct
// that might be logged (US-012).
func EnvConfigLayer(getenv func(string) string) ConfigLayer {
if getenv == nil {
getenv = os.Getenv
}
var layer ConfigLayer
if v := getenv("PIGO_MODEL"); v != "" {
layer.Model = &v
}
if v := getenv("PIGO_PROVIDER"); v != "" {
layer.Provider = &v
}
if v := getenv("PIGO_TOOL_EXECUTION_MODE"); v != "" {
layer.ToolExecutionMode = &v
}
if v := getenv("PIGO_THINKING_LEVEL"); v != "" {
layer.ThinkingLevel = &v
}
return layer
}
// validate reports the first invalid field in the resolved config: an unknown
// tool-execution mode or thinking level. An empty model is also rejected, since
// a run cannot proceed without one.
func (c Config) validate() error {
if c.Model == "" {
return fmt.Errorf("config: model must not be empty")
}
switch c.ToolExecutionMode {
case agentcore.ToolExecutionParallel, agentcore.ToolExecutionSequential:
default:
return fmt.Errorf("config: invalid toolExecutionMode %q (want parallel|sequential)", c.ToolExecutionMode)
}
switch c.ThinkingLevel {
case agentcore.ThinkingOff, agentcore.ThinkingMinimal, agentcore.ThinkingLow, agentcore.ThinkingMedium, agentcore.ThinkingHigh, agentcore.ThinkingXHigh, agentcore.ThinkingMax:
default:
return fmt.Errorf("config: invalid thinkingLevel %q", c.ThinkingLevel)
}
return nil
}
+240
View File
@@ -0,0 +1,240 @@
package runtime
// Tests for the layered configuration system (US-023, #42): the precedence
// order (default < global < project < env/CLI), per-provider credential merge,
// and the hard-error paths for malformed files and invalid field values.
import (
"os"
"path/filepath"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
)
// ptr is a helper for building pointer-valued config-layer fields in tests.
func ptr[T any](v T) *T { return &v }
// TestResolveConfigPrecedence is the acceptance-critical test: a field set in a
// higher layer overrides the same field in every lower layer, and the winner is
// always the highest layer that set it.
func TestResolveConfigPrecedence(t *testing.T) {
def := DefaultConfigLayer()
global := &ConfigLayer{Model: ptr("global/model"), ThinkingLevel: ptr("low")}
project := &ConfigLayer{Model: ptr("project/model")}
env := &ConfigLayer{Model: ptr("env/model"), Provider: ptr("bedrock")}
cfg, err := ResolveConfig(&def, global, project, env)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
// Model set in all four layers → env (highest) wins.
if cfg.Model != "env/model" {
t.Errorf("Model = %q, want env/model (highest layer wins)", cfg.Model)
}
// Provider set only in env → env value.
if cfg.Provider != "bedrock" {
t.Errorf("Provider = %q, want bedrock", cfg.Provider)
}
// ThinkingLevel set in global only (not project/env) → global value shows through.
if cfg.ThinkingLevel != agentcore.ThinkingLow {
t.Errorf("ThinkingLevel = %q, want low (from global, lower layers don't set it)", cfg.ThinkingLevel)
}
// ToolExecutionMode set only in default → default shows through.
if cfg.ToolExecutionMode != agentcore.ToolExecutionParallel {
t.Errorf("ToolExecutionMode = %q, want parallel (default)", cfg.ToolExecutionMode)
}
}
// TestResolveConfigCredentialMerge verifies credentials merge per-provider: a
// higher layer overrides one provider's key while a lower layer's other-provider
// key is retained.
func TestResolveConfigCredentialMerge(t *testing.T) {
def := DefaultConfigLayer()
global := &ConfigLayer{Credentials: map[string]string{"openrouter": "or-low", "ollama": "ol-key"}}
project := &ConfigLayer{Credentials: map[string]string{"openrouter": "or-high"}}
cfg, err := ResolveConfig(&def, global, project)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if cfg.Credentials["openrouter"] != "or-high" {
t.Errorf("openrouter key = %q, want or-high (project overrides global)", cfg.Credentials["openrouter"])
}
if cfg.Credentials["ollama"] != "ol-key" {
t.Errorf("ollama key = %q, want ol-key (retained from global)", cfg.Credentials["ollama"])
}
}
// TestResolveConfigInvalidValues verifies invalid field values are hard errors.
func TestResolveConfigInvalidValues(t *testing.T) {
def := DefaultConfigLayer()
cases := []struct {
name string
layer *ConfigLayer
}{
{"bad mode", &ConfigLayer{ToolExecutionMode: ptr("concurrent")}},
{"bad thinking", &ConfigLayer{ThinkingLevel: ptr("ultra")}},
{"empty model", &ConfigLayer{Model: ptr("")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := ResolveConfig(&def, tc.layer); err == nil {
t.Errorf("%s must be a hard error, got nil", tc.name)
}
})
}
}
// TestLoadConfigLayerMissingAndMalformed verifies a missing file is not an error
// (nil layer) while a malformed file is.
func TestLoadConfigLayerMissingAndMalformed(t *testing.T) {
dir := t.TempDir()
// Missing file → nil, nil.
layer, err := LoadConfigLayer(filepath.Join(dir, "nope.json"))
if err != nil || layer != nil {
t.Errorf("missing file: got (%v, %v), want (nil, nil)", layer, err)
}
// Malformed file → error.
bad := filepath.Join(dir, "bad.json")
if err := os.WriteFile(bad, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfigLayer(bad); err == nil {
t.Error("malformed config file must return an error")
}
// Well-formed file → decoded layer.
good := filepath.Join(dir, "good.json")
if err := os.WriteFile(good, []byte(`{"model":"m","provider":"p"}`), 0o600); err != nil {
t.Fatal(err)
}
layer, err = LoadConfigLayer(good)
if err != nil {
t.Fatalf("good file: %v", err)
}
if layer == nil || layer.Model == nil || *layer.Model != "m" {
t.Errorf("good file decoded incorrectly: %+v", layer)
}
}
// TestEnvConfigLayer verifies env vars map to the right fields and unset vars
// leave fields nil.
func TestEnvConfigLayer(t *testing.T) {
env := map[string]string{
"PIGO_MODEL": "env/m",
"PIGO_THINKING_LEVEL": "high",
"PIGO_TOOL_EXECUTION_MODE": "sequential",
}
layer := EnvConfigLayer(func(k string) string { return env[k] })
if layer.Model == nil || *layer.Model != "env/m" {
t.Errorf("PIGO_MODEL not captured: %+v", layer.Model)
}
if layer.ThinkingLevel == nil || *layer.ThinkingLevel != "high" {
t.Errorf("PIGO_THINKING_LEVEL not captured: %+v", layer.ThinkingLevel)
}
if layer.ToolExecutionMode == nil || *layer.ToolExecutionMode != "sequential" {
t.Errorf("PIGO_TOOL_EXECUTION_MODE not captured: %+v", layer.ToolExecutionMode)
}
// Unset var → nil field.
if layer.Provider != nil {
t.Errorf("unset PIGO_PROVIDER should leave Provider nil, got %v", *layer.Provider)
}
}
// TestResolveConfigDefaultsAlone verifies the default layer alone yields a valid
// config.
func TestResolveConfigDefaultsAlone(t *testing.T) {
def := DefaultConfigLayer()
cfg, err := ResolveConfig(&def)
if err != nil {
t.Fatalf("default-only config must be valid: %v", err)
}
if cfg.Model == "" || cfg.ToolExecutionMode == "" || cfg.ThinkingLevel == "" {
t.Errorf("default config incomplete: %+v", cfg)
}
}
// TestResolveConfigHooksAppendMerge verifies hooks are append-merged per event
// type across layers (FR-2) in ascending order, not overridden like scalars.
func TestResolveConfigHooksAppendMerge(t *testing.T) {
def := DefaultConfigLayer()
global := &ConfigLayer{Hooks: hooks.HookSet{
"PreToolUse": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: "global-pre"}}}},
"Stop": {{Matcher: "", Hooks: []hooks.HookConfig{{Command: "global-stop"}}}},
}}
project := &ConfigLayer{Hooks: hooks.HookSet{
"PreToolUse": {{Matcher: "bash", Hooks: []hooks.HookConfig{{Command: "project-pre"}}}},
}}
cfg, err := ResolveConfig(&def, global, project)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
pre := cfg.Hooks["PreToolUse"]
if len(pre) != 2 {
t.Fatalf("PreToolUse matchers = %d, want 2 (global+project appended)", len(pre))
}
// Ascending layer order: global before project.
if pre[0].Hooks[0].Command != "global-pre" || pre[1].Hooks[0].Command != "project-pre" {
t.Errorf("PreToolUse order wrong: %q, %q", pre[0].Hooks[0].Command, pre[1].Hooks[0].Command)
}
if len(cfg.Hooks["Stop"]) != 1 {
t.Errorf("Stop matchers = %d, want 1 (only global set it)", len(cfg.Hooks["Stop"]))
}
}
// TestResolveConfigHooksNilWhenAbsent verifies the no-hooks path leaves
// cfg.Hooks nil (FR-18), so downstream can cheaply skip hook dispatch.
func TestResolveConfigHooksNilWhenAbsent(t *testing.T) {
def := DefaultConfigLayer()
cfg, err := ResolveConfig(&def)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if cfg.Hooks != nil {
t.Errorf("Hooks = %v, want nil when no layer defines hooks", cfg.Hooks)
}
// An empty (but non-nil) hook map in a layer must not allocate cfg.Hooks.
empty := &ConfigLayer{Hooks: hooks.HookSet{"PreToolUse": {}}}
cfg2, err := ResolveConfig(&def, empty)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if cfg2.Hooks != nil {
t.Errorf("Hooks = %v, want nil when layer's event has no matchers", cfg2.Hooks)
}
}
// TestLoadConfigLayerHooks verifies a layer's hooks decode from JSON, including
// per-hook timeout and matcher fields.
func TestLoadConfigLayerHooks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
body := `{
"model": "x/y",
"hooks": {
"PreToolUse": [
{"matcher": "bash", "hooks": [{"type": "command", "command": "echo hi", "timeout": 5}]}
]
}
}`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
layer, err := LoadConfigLayer(path)
if err != nil {
t.Fatalf("LoadConfigLayer: %v", err)
}
pre := layer.Hooks["PreToolUse"]
if len(pre) != 1 || pre[0].Matcher != "bash" {
t.Fatalf("unexpected matchers: %+v", pre)
}
h := pre[0].Hooks[0]
if h.Command != "echo hi" || h.TimeoutSeconds() != 5 {
t.Errorf("unexpected hook: cmd=%q timeout=%d", h.Command, h.TimeoutSeconds())
}
}
@@ -0,0 +1,149 @@
package runtime
// End-to-end robustness verification scenarios (US-007 / FR-10): the harness's
// two self-protection mechanisms must fire correctly when driven through the
// real provider seam with NO external LLM.
//
// - LONG SESSION: a run whose accumulated context exceeds the usable window
// (ContextWindow ReserveTokens) must auto-compact in place, emitting a
// successful CompactionEvent, and still finish normally.
// - LARGE OUTPUT: a tool that returns far more than the executor-layer byte
// budget (toolResultMaxBytes = 100_000) must have its result truncated with
// the shared "[truncated" annotation, so a single fat tool result cannot
// overflow the model context, and the run still finishes.
//
// Both scenarios reuse the existing in-repo test infrastructure only: the faux
// provider seam (StreamFnFromProvider via newFauxRunCfg / toolCallTurn / textTurn),
// the scripted StreamFn (scriptedStream / newRunCfg / summaryStream), and the
// event collectors (collectEvents / collectStream / findCompaction). No new
// mocking is invented and no production (non-_test) code is touched.
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
)
// TestE2E_LongSession_TriggersCompaction drives a long session whose seeded
// history already exceeds the usable window, forcing the loop to auto-compact
// after the first turn settles. It asserts a *successful* CompactionEvent is
// emitted (empty ErrorMessage), tokens shrink, a compaction checkpoint replaces
// the head of the context, and the run ends normally via agent_end.
func TestE2E_LongSession_TriggersCompaction(t *testing.T) {
// Main stream just ends the turn; a separate summary stream stands in for the
// summarization model so compaction does not consume main-stream turns.
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ack")}},
})
cfg := newRunCfg(main)
cfg.SummaryStream = summaryStream("## Goal\nlong session compacted")
// Deliberately tiny window so a handful of fat seeded messages exceed the
// threshold (ContextWindow ReserveTokens) the moment the first turn settles.
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
// Seed a long history: 16 messages × 800 chars each blows past the usable window.
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(16, 800)}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
ce := findCompaction(events)
if ce == nil {
t.Fatalf("long session must trigger a CompactionEvent, got %v", eventKinds(events))
}
if ce.ErrorMessage != "" {
t.Fatalf("compaction must succeed, got error %q", ce.ErrorMessage)
}
if ce.TokensAfter >= ce.TokensBefore {
t.Errorf("compaction must reduce estimated tokens: before=%d after=%d", ce.TokensBefore, ce.TokensAfter)
}
if ce.SummarizedCount <= 0 {
t.Errorf("compaction must fold at least one message into the summary, got %d", ce.SummarizedCount)
}
// The compacted context must begin with a compaction checkpoint.
if len(agentCtx.Messages) == 0 || agentCtx.Messages[0].Role() != agentcore.RoleCompaction {
t.Errorf("context must start with a compaction checkpoint after compaction, got %+v", agentCtx.Messages)
}
// The run must still terminate cleanly.
if n := len(events); n == 0 || events[n-1].EventType() != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end, got %v", eventKinds(events))
}
}
// TestE2E_LargeOutput_TriggersTruncation drives a tool call whose tool returns
// output far larger than the executor-layer byte budget. It asserts the
// resulting tool-result content carries the shared "[truncated" annotation and
// is clipped well below the raw size (so the context cannot overflow from a
// single fat result), and the run still completes normally.
func TestE2E_LargeOutput_TriggersTruncation(t *testing.T) {
// A payload well over toolResultMaxBytes (100_000). 250_000 bytes guarantees
// the executor-layer budget bites regardless of any looser inner cap.
const rawSize = 250_000
huge := strings.Repeat("A", rawSize)
// A tool that emits the oversized payload as a single text block.
bigOutputTool := execTool{
name: "flood",
mode: agentcore.ToolExecutionParallel,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(huge)}}, nil
},
}
// Turn 1: call the flooding tool. Turn 2: end the turn with text.
p := &fauxProvider{
name: "faux",
turns: []fauxTurn{
toolCallTurn("call-flood", "flood", `{}`),
textTurn("handled large output"),
},
}
cfg := newFauxRunCfg(p, bigOutputTool)
// A generous window: the point is that truncation keeps the result small
// enough that the context does NOT overflow, so no compaction is needed.
cfg.ContextWindow = 200_000
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("run flood")}}}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
// Locate the flood tool result and assert it was truncated.
var floodResult *agentcore.ToolResultMessage
for i := range msgs {
if tr, ok := msgs[i].(agentcore.ToolResultMessage); ok && tr.ToolCallID == "call-flood" {
trCopy := tr
floodResult = &trCopy
}
}
if floodResult == nil {
t.Fatalf("expected a tool result for the flood call, got %+v", msgs)
}
got := textContentOf(floodResult.Content)
if !strings.Contains(got, "[truncated") {
t.Errorf("large tool output must carry the \"[truncated\" annotation, got %d bytes without it", len(got))
}
// The clipped result must be far smaller than the raw payload — the context
// protection actually reduced the size (it must not blow the 100_000 budget
// wildly; allow generous headroom for head+tail+marker).
if len(got) >= rawSize {
t.Errorf("truncation must shrink the result: got %d bytes, raw was %d", len(got), rawSize)
}
if len(got) > 120_000 {
t.Errorf("truncated result should be near the byte budget, got %d bytes", len(got))
}
// Context must not have overflowed: with truncation in place the tiny clipped
// result never crosses the window, so no compaction should have fired.
for _, ev := range kinds {
if ev == agentcore.EventCompaction {
t.Errorf("truncation should keep context under the window; no compaction expected, got kinds %v", kinds)
}
}
// And the run finished cleanly.
if len(kinds) == 0 || kinds[len(kinds)-1] != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end, got %v", kinds)
}
}
+429
View File
@@ -0,0 +1,429 @@
package runtime
// This file implements the faux provider (mirrors pi providers/faux.ts) and the
// loop integration tests that drive the whole agent loop through it — the
// project's primary and only core test seam (US-002 / Testing Decisions, #16).
//
// Unlike loop_test.go, which drives the loop with a coarse StreamFn that emits
// only a terminal StreamDoneEvent, the faux provider is a real Provider whose
// StreamCompletion replays a *fine-grained* script of AssistantMessageEvents
// (start → text/toolcall deltas → done) — one scripted turn per call. It is
// wired into the loop via StreamFnFromProvider, the real seam, so the whole
// path (message_start / message_update / message_end deltas, the six hooks,
// truncation protection, parallel ordering, and EventStream cancellation) is
// covered end to end without mocking any loop-internal function.
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/provider"
)
// fauxTurn is one scripted assistant turn: the fine-grained stream events the
// faux provider replays for a single StreamCompletion call.
type fauxTurn []provider.AssistantMessageEvent
// textTurn scripts a turn that streams text as start → text delta → done(end_turn).
func textTurn(text string) fauxTurn {
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
withText := partial
withText.Content = agentcore.ContentList{agentcore.NewTextContent(text)}
final := withText
final.StopReason = agentcore.StopReasonEndTurn
return fauxTurn{
provider.StreamStartEvent{Partial: partial},
provider.StreamTextEvent{Partial: withText},
provider.StreamDoneEvent{Message: final},
}
}
// toolCallTurn scripts a turn that streams one tool call as
// start → toolcall delta → done(tool_use).
func toolCallTurn(id, name, args string) fauxTurn {
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
withCall := partial
withCall.Content = agentcore.ContentList{agentcore.NewToolCallContent(id, name, json.RawMessage(args))}
final := withCall
final.StopReason = agentcore.StopReasonToolUse
return fauxTurn{
provider.StreamStartEvent{Partial: partial},
provider.StreamToolCallEvent{Partial: withCall},
provider.StreamDoneEvent{Message: final},
}
}
// fauxProvider is a real Provider that replays one scripted turn per
// StreamCompletion call, in order. It records every request it received so
// tests can assert what the loop actually sent (model, context, config). Once
// the script is exhausted it replays a plain end_turn turn.
type fauxProvider struct {
name string
models []provider.Model
turns []fauxTurn
mu sync.Mutex
calls int
requests []provider.CompletionRequest
// delay optionally slows each delta emit, used by the cancellation test to
// keep the stream open long enough to cancel mid-flight.
delay time.Duration
}
func (p *fauxProvider) Name() string { return p.name }
func (p *fauxProvider) Models() []provider.Model { return p.models }
func (p *fauxProvider) callCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls
}
func (p *fauxProvider) requestAt(i int) provider.CompletionRequest {
p.mu.Lock()
defer p.mu.Unlock()
return p.requests[i]
}
func (p *fauxProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) {
p.mu.Lock()
idx := p.calls
p.calls++
p.requests = append(p.requests, req)
var turn fauxTurn
if idx < len(p.turns) {
turn = p.turns[idx]
} else {
turn = textTurn("")
}
delay := p.delay
p.mu.Unlock()
s := provider.NewAssistantMessageEventStream(0)
go func() {
for _, ev := range turn {
if delay > 0 {
select {
case <-time.After(delay):
case <-ctx.Done():
s.SetError(ctx.Err())
s.Close()
return
}
}
if err := s.Emit(ctx, ev); err != nil {
s.SetError(err)
s.Close()
return
}
}
s.Close()
}()
return s, nil
}
// newFauxRunCfg wires a faux provider into the loop via StreamFnFromProvider
// (the real seam) and registers the given tools. No loop-internal function is
// mocked — only the provider boundary.
func newFauxRunCfg(p *fauxProvider, tools ...agentcore.AgentTool) RunConfig {
reg := agenttool.NewToolRegistry()
for _, tl := range tools {
_ = reg.Register(tl)
}
return RunConfig{
LoopConfig: LoopConfig{Model: "faux", Stream: provider.StreamFnFromProvider(p)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}},
}
}
// TestFauxProviderTextToolText drives the flagship seam scenario end to end:
// text → tool call → text over the real loop, asserting both the AgentEvent
// stream shape and the final []AgentMessage. Nothing loop-internal is mocked.
func TestFauxProviderTextToolText(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{
textTurn("thinking about it"), // turn 1: plain text, no tool
toolCallTurn("call-1", "echo", `{"msg":"hello"}`), // turn 2: tool call
textTurn("all done"), // turn 3: final text
},
}
// GetFollowUpMessages injects a follow-up once so the loop advances past the
// first natural (text-only) turn end into the tool-call turn.
served := false
cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
cfg.GetFollowUpMessages = func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage {
if served {
return nil
}
served = true
return []agentcore.AgentMessage{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("go on")}}}
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
// Event shape: message deltas must appear (start/update/end), a tool
// executed exactly once, and the run bookended by agent_start/agent_end.
if kinds[0] != agentcore.EventAgentStart || kinds[len(kinds)-1] != agentcore.EventAgentEnd {
t.Fatalf("run must be bracketed by agent_start/agent_end, got %v", kinds)
}
if countKind(kinds, agentcore.EventMessageStart) < 3 || countKind(kinds, agentcore.EventMessageEnd) < 3 {
t.Errorf("expected fine-grained message deltas for each turn, got %v", kinds)
}
if countKind(kinds, agentcore.EventMessageUpdate) < 3 {
t.Errorf("expected message_update deltas (text/toolcall), got %v", kinds)
}
if got := countKind(kinds, agentcore.EventToolExecutionStart); got != 1 {
t.Errorf("expected 1 tool_execution_start, got %d in %v", got, kinds)
}
if got := countKind(kinds, agentcore.EventToolExecutionEnd); got != 1 {
t.Errorf("expected 1 tool_execution_end, got %d in %v", got, kinds)
}
if got := countKind(kinds, agentcore.EventTurnStart); got != 3 {
t.Errorf("expected 3 turns (text→tool→text), got %d in %v", got, kinds)
}
// Final messages: assistant(text) + user(follow-up) + assistant(tool) +
// toolResult + assistant(text) = 5, in order.
if len(msgs) != 5 {
t.Fatalf("expected 5 new messages, got %d: %+v", len(msgs), msgs)
}
if a, ok := msgs[0].(agentcore.AssistantMessage); !ok || textContentOf(a.Content) != "thinking about it" {
t.Errorf("msg[0] should be the first text assistant message, got %T %+v", msgs[0], msgs[0])
}
if _, ok := msgs[1].(agentcore.UserMessage); !ok {
t.Errorf("msg[1] should be the injected follow-up user message, got %T", msgs[1])
}
if a, ok := msgs[2].(agentcore.AssistantMessage); !ok || len(a.ToolCalls()) != 1 {
t.Errorf("msg[2] should be the tool-call assistant message, got %T %+v", msgs[2], msgs[2])
}
tr, ok := msgs[3].(agentcore.ToolResultMessage)
if !ok || tr.ToolCallID != "call-1" || tr.IsError {
t.Errorf("msg[3] should be the successful echo tool result, got %T %+v", msgs[3], msgs[3])
}
if a, ok := msgs[4].(agentcore.AssistantMessage); !ok || textContentOf(a.Content) != "all done" {
t.Errorf("msg[4] should be the final text assistant message, got %T %+v", msgs[4], msgs[4])
}
// The loop must have driven the provider exactly three times, each carrying
// the growing context and the configured model.
if p.callCount() != 3 {
t.Fatalf("provider called %d times, want 3", p.callCount())
}
if req := p.requestAt(0); req.Model != "faux" {
t.Errorf("provider request model = %q, want faux", req.Model)
}
}
// textContentOf returns the concatenated text of a content list.
func textContentOf(list agentcore.ContentList) string {
var s string
for _, c := range list {
if tc, ok := c.(agentcore.TextContent); ok {
s += tc.Text
}
}
return s
}
// TestFauxSeamSixHooks exercises all six loop hooks through the real seam in a
// single run: the two per-request LoopConfig hooks (TransformContext,
// ConvertToLlm resolved via GetAPIKey) and the four RunConfig hooks
// (GetFollowUpMessages, GetSteeringMessages, PrepareNextTurn,
// ShouldStopAfterTurn). Each hook records that it fired and, where observable,
// that its effect reached the provider request.
func TestFauxSeamSixHooks(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{
toolCallTurn("call-1", "echo", `{}`), // turn 1: tool → afterTurn hooks fire
textTurn("second"), // turn 2: end (after model swap)
},
}
var fired struct {
transform, convert, apiKey, followUp, steering, prepare, shouldStop bool
}
swapped := "swapped-model"
cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
cfg.Provider = "faux"
cfg.TransformContext = func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList {
fired.transform = true
return msgs
}
cfg.ConvertToLlm = func(msgs agentcore.MessageList) agentcore.MessageList {
fired.convert = true
return msgs
}
cfg.GetAPIKey = func(ctx context.Context, provider string) string {
fired.apiKey = true
return "dyn-key"
}
cfg.GetSteeringMessages = func(ctx context.Context) []agentcore.AgentMessage {
fired.steering = true
return nil
}
cfg.PrepareNextTurn = func(ctx context.Context, agentCtx *agentcore.AgentContext) *TurnUpdate {
fired.prepare = true
return &TurnUpdate{Model: &swapped}
}
stopCalls := 0
cfg.ShouldStopAfterTurn = func(ctx context.Context, agentCtx *agentcore.AgentContext) bool {
fired.shouldStop = true
stopCalls++
return false // never stop early; let the run end naturally
}
cfg.GetFollowUpMessages = func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage {
fired.followUp = true
// No follow-up: the tool-call turn already drives turn 2, so the run
// ends naturally after the second turn.
return nil
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}}}}
collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if !fired.transform || !fired.convert || !fired.apiKey {
t.Errorf("per-request hooks not all fired: %+v", fired)
}
if !fired.followUp || !fired.steering || !fired.prepare || !fired.shouldStop {
t.Errorf("per-turn hooks not all fired: %+v", fired)
}
if stopCalls == 0 {
t.Error("ShouldStopAfterTurn was never consulted")
}
// GetAPIKey's dynamic key must have reached the provider request config.
if got := p.requestAt(0).Config.APIKey; got != "dyn-key" {
t.Errorf("GetAPIKey result not threaded to provider, APIKey = %q", got)
}
// PrepareNextTurn swapped the model before turn 2.
if p.callCount() >= 2 {
if got := p.requestAt(1).Config.APIKey; got != "dyn-key" {
t.Errorf("turn 2 APIKey = %q, want dyn-key", got)
}
if got := p.requestAt(1).Model; got != swapped {
t.Errorf("PrepareNextTurn model swap not applied, turn 2 model = %q, want %q", got, swapped)
}
}
}
// TestFauxSeamTruncationProtection verifies that a truncated (stopReason=length)
// tool-call turn is protected: the tool is NOT executed and a synthesized failed
// tool result is fed back, all through the seam.
func TestFauxSeamTruncationProtection(t *testing.T) {
// Turn 1: a tool call that arrives truncated. Turn 2: end.
truncPartial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("t1", "echo", json.RawMessage(`{}`))}}
truncFinal := truncPartial
truncFinal.StopReason = agentcore.StopReasonLength
p := &fauxProvider{
name: "faux",
turns: []fauxTurn{
{
provider.StreamStartEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}},
provider.StreamToolCallEvent{Partial: truncPartial},
provider.StreamDoneEvent{Message: truncFinal},
},
textTurn("recovered"),
},
}
cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if countKind(kinds, agentcore.EventToolExecutionEnd) != 0 {
t.Errorf("truncated tool call must not execute, got %v", kinds)
}
var foundFail bool
for _, m := range msgs {
if tr, ok := m.(agentcore.ToolResultMessage); ok && tr.IsError && tr.ToolCallID == "t1" {
foundFail = true
}
}
if !foundFail {
t.Errorf("expected a synthesized failed tool result for the truncated call, got %+v", msgs)
}
}
// TestFauxSeamParallelOrderingPreserved verifies that a turn with multiple
// parallel tool calls yields tool results in source order regardless of which
// tool finishes first, driven through the seam.
func TestFauxSeamParallelOrderingPreserved(t *testing.T) {
// One assistant turn with three tool calls in a fixed order; the tools sleep
// in reverse so completion order differs from source order.
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{
agentcore.NewToolCallContent("a0", "slow", json.RawMessage(`{}`)),
agentcore.NewToolCallContent("a1", "mid", json.RawMessage(`{}`)),
agentcore.NewToolCallContent("a2", "fast", json.RawMessage(`{}`)),
}}
final := partial
final.StopReason = agentcore.StopReasonToolUse
p := &fauxProvider{
turns: []fauxTurn{
{provider.StreamStartEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}}, provider.StreamToolCallEvent{Partial: partial}, provider.StreamDoneEvent{Message: final}},
textTurn("done"),
},
}
mk := func(name string, delay time.Duration) execTool {
return execTool{
name: name,
mode: agentcore.ToolExecutionParallel,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
time.Sleep(delay)
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
},
}
}
cfg := newFauxRunCfg(p, mk("slow", 25*time.Millisecond), mk("mid", 12*time.Millisecond), mk("fast", 1*time.Millisecond))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
_, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
var order []string
for _, m := range msgs {
if tr, ok := m.(agentcore.ToolResultMessage); ok {
order = append(order, tr.ToolCallID)
}
}
want := []string{"a0", "a1", "a2"}
if len(order) != 3 || order[0] != want[0] || order[1] != want[1] || order[2] != want[2] {
t.Errorf("parallel tool results out of source order: got %v, want %v", order, want)
}
}
// TestFauxSeamStreamCancellation verifies that cancelling the context stops the
// run: the consumer stops receiving events and Result reports the cancellation,
// exercised through the seam with a provider that streams slowly.
func TestFauxSeamStreamCancellation(t *testing.T) {
p := &fauxProvider{
turns: []fauxTurn{textTurn("never fully delivered")},
delay: 50 * time.Millisecond, // slow enough to cancel mid-stream
}
cfg := newFauxRunCfg(p)
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
ctx, cancel := context.WithCancel(context.Background())
s := agentLoop(ctx, agentCtx, cfg)
// Read the first event, then cancel while the provider is still streaming.
<-s.Events()
cancel()
// Drain remaining events (must terminate, not hang).
for range s.Events() {
}
if _, err := s.Result(context.Background()); err != nil {
// A set result is also acceptable (the run may have finished emitting
// agent_end before cancellation propagated); but if an error is set it
// must be the cancellation.
if err != context.Canceled {
t.Errorf("cancelled run result error = %v, want context.Canceled or nil", err)
}
}
}
+259
View File
@@ -0,0 +1,259 @@
// This file implements the headless / stdio run modes (US-020, FR-18): a
// non-interactive driver that runs the agent loop over a single prompt for
// scripting and CI. Two output modes are supported, mirroring pi's print-mode
// and rpc/stream-json protocols:
//
// - PrintMode: run the loop to completion and write only the final assistant
// text to the output (the "-p / --print" mode).
// - StreamJSONMode: serialize every AgentEvent as a line-delimited JSON object
// as it is emitted (the "--output-format stream-json" mode), so a parent
// process can consume the run incrementally.
//
// The run's success/failure is reported as a returned error so the CLI can map
// it to a process exit code: a run whose final assistant message carries
// stopReason error/aborted, or whose stream result errors, is a failure.
package runtime
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// HeadlessMode selects how a headless run reports its progress and result.
type HeadlessMode int
const (
// PrintMode runs the loop to completion and writes only the final assistant
// text to the output writer.
PrintMode HeadlessMode = iota
// StreamJSONMode writes each AgentEvent as a line-delimited JSON object as it
// is emitted.
StreamJSONMode
)
// HeadlessConfig configures a headless run.
type HeadlessConfig struct {
// Run is the loop configuration (provider stream, tools, hooks).
Run RunConfig
// Mode selects print vs stream-json output. Defaults to PrintMode.
Mode HeadlessMode
// Out receives the run output (final text or JSON lines). Required.
Out io.Writer
// OnEvent, when non-nil, is invoked for every AgentEvent before output
// handling. It is the seam plugin lifecycle-event delivery (US-017, #133)
// hooks into, independent of the output mode. It must not block.
OnEvent func(ev agentcore.AgentEvent)
// Progress receives human-readable sub-agent progress lines
// (SubAgentProgressEvent). Defaults to os.Stderr when nil. Progress is
// stderr-only by contract: it is never serialised onto Out (stdout), so it
// cannot pollute the final result text or the stream-json envelope stream.
Progress io.Writer
}
// ErrRunFailed is the sentinel returned by RunHeadless when the agent run ended
// in a failure state (stopReason error/aborted). The CLI maps a non-nil error
// to a non-zero exit code.
type ErrRunFailed struct {
// Reason is the stopReason (or message) that marked the run as failed.
Reason string
}
func (e *ErrRunFailed) Error() string {
if e.Reason == "" {
return "agent run failed"
}
return "agent run failed: " + e.Reason
}
// RunHeadless runs the agent loop for the already-assembled agentCtx and drives
// output per cfg.Mode. It blocks until the run ends and returns nil on success
// or an error describing the failure (for exit-code mapping). It never returns
// before the stream is fully drained, so no goroutine is leaked.
func RunHeadless(ctx context.Context, agentCtx *agentcore.AgentContext, cfg HeadlessConfig) error {
if cfg.Out == nil {
return fmt.Errorf("headless: nil output writer")
}
stream := agentLoop(ctx, agentCtx, cfg.Run)
// writeErr holds the first stream-json write failure. We keep draining after
// it (DrainStream never returns early) so the loop's producer goroutine never
// blocks on a synchronous Emit — honoring the no-leak contract on a broken
// pipe. In stream-json mode every event is serialised; print mode only needs
// the final message, which DrainStream returns.
var writeErr error
h := StreamHandler{}
// Compose the output-mode serialiser (stream-json only) with the optional
// external OnEvent (plugin lifecycle delivery, US-017). Both observe every
// event; the serialiser runs first so a write failure is recorded even when a
// plugin observer is also wired.
//
// SubAgentProgressEvent (D-9) is special-cased: it is written as a
// human-readable line to the progress writer (stderr) and is deliberately
// excluded from the stream-json stdout path so it never pollutes the result
// output or the machine-readable envelope stream (progress is stderr-only).
progress := cfg.Progress
if progress == nil {
progress = os.Stderr
}
streamJSON := cfg.Mode == StreamJSONMode
h.OnEvent = func(ev agentcore.AgentEvent) {
if pe, ok := ev.(agentcore.SubAgentProgressEvent); ok {
writeProgressLine(progress, pe)
if cfg.OnEvent != nil {
cfg.OnEvent(ev)
}
return
}
if streamJSON && writeErr == nil {
if err := writeEventJSON(cfg.Out, ev); err != nil {
writeErr = err
}
}
if cfg.OnEvent != nil {
cfg.OnEvent(ev)
}
}
lastAssistant, resErr := DrainStream(ctx, stream, h)
if writeErr != nil {
return writeErr
}
if resErr != nil {
return resErr
}
if cfg.Mode == PrintMode {
text := ""
if lastAssistant != nil {
text = agentcore.ContentToText(lastAssistant.Content)
}
if _, err := io.WriteString(cfg.Out, text); err != nil {
return err
}
if text != "" && !strings.HasSuffix(text, "\n") {
if _, err := io.WriteString(cfg.Out, "\n"); err != nil {
return err
}
}
}
if lastAssistant != nil {
switch lastAssistant.StopReason {
case agentcore.StopReasonError:
reason := lastAssistant.ErrorMessage
if reason == "" {
reason = "error"
}
return &ErrRunFailed{Reason: reason}
case agentcore.StopReasonAborted:
return &ErrRunFailed{Reason: "aborted"}
}
}
return nil
}
// writeProgressLine renders one SubAgentProgressEvent as a human-readable line
// on w (stderr by contract). When the task supplied a description it is shown
// alongside the activity; otherwise the line degrades to the activity alone
// (Description MAY be empty, Activity never is). Write errors are ignored:
// progress is a non-critical, best-effort side channel.
func writeProgressLine(w io.Writer, ev agentcore.SubAgentProgressEvent) {
if ev.Description != "" {
fmt.Fprintf(w, " ⏺ %s · %s\n", ev.Description, ev.Activity)
return
}
fmt.Fprintf(w, " ⏺ %s\n", ev.Activity)
}
// writeEventJSON serializes one AgentEvent as a single line of JSON, terminated
// by a newline, onto w. The envelope always carries a "type" discriminant so a
// consumer can dispatch without positional knowledge.
func writeEventJSON(w io.Writer, ev agentcore.AgentEvent) error {
env := eventEnvelope(ev)
b, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("headless: marshal event: %w", err)
}
b = append(b, '\n')
_, err = w.Write(b)
return err
}
// eventEnvelope maps an AgentEvent onto a JSON-serializable object with a
// "type" discriminant plus the event's observable payload. Only fields that are
// safe and useful over the wire are included (assistant text, tool ids/names,
// stop reasons) — never secrets.
func eventEnvelope(ev agentcore.AgentEvent) map[string]any {
env := map[string]any{"type": ev.EventType()}
switch e := ev.(type) {
case agentcore.AgentStartEvent:
// The first event carries the backing session id (mirrors pi/Claude Code),
// so a consumer can associate the run's output with a session and resume
// it later. Omitted only when the run has no backing session (SessionID
// unset), which the envelope treats as "not resumable".
if e.SessionID != "" {
env["sessionId"] = e.SessionID
}
case agentcore.AgentEndEvent:
env["messageCount"] = len(e.Messages)
case agentcore.TurnEndEvent:
env["stopReason"] = e.Message.StopReason
if text := agentcore.ContentToText(e.Message.Content); text != "" {
env["text"] = text
}
if calls := e.Message.ToolCalls(); len(calls) > 0 {
names := make([]string, len(calls))
for i, c := range calls {
names[i] = c.Name
}
env["toolCalls"] = names
}
case agentcore.MessageUpdateEvent:
if a, ok := e.Message.(agentcore.AssistantMessage); ok {
if text := agentcore.ContentToText(a.Content); text != "" {
env["text"] = text
}
}
case agentcore.ToolExecutionStartEvent:
env["toolCallId"] = e.ToolCallID
env["toolName"] = e.ToolName
case agentcore.ToolExecutionEndEvent:
env["toolCallId"] = e.ToolCallID
env["toolName"] = e.ToolName
env["isError"] = e.IsError
case agentcore.CompactionStartEvent:
env["reason"] = e.Reason
env["tokensBefore"] = e.TokensBefore
case agentcore.CompactionEvent:
env["reason"] = e.Reason
env["tokensBefore"] = e.TokensBefore
env["tokensAfter"] = e.TokensAfter
env["summarizedCount"] = e.SummarizedCount
env["keptCount"] = e.KeptCount
if e.ErrorMessage != "" {
env["error"] = e.ErrorMessage
}
case agentcore.TelemetryEvent:
// The run-end telemetry summary: structured metrics a script can read
// directly from the stream-json output (observability -- structured telemetry collection). Per-tool
// timings are flattened into a name→{count,totalMs} object so a JSON
// consumer can index by tool name.
env["turns"] = e.Turns
env["truncationCount"] = e.TruncationCount
env["compactionCount"] = e.CompactionCount
env["contextUtilization"] = e.ContextUtilization
env["contextTokens"] = e.ContextTokens
env["contextWindow"] = e.ContextWindow
tools := make(map[string]map[string]any, len(e.ToolDurationsMs))
for name, t := range e.ToolDurationsMs {
tools[name] = map[string]any{"count": t.Count, "totalMs": t.TotalMs}
}
env["toolDurationsMs"] = tools
}
return env
}
+292
View File
@@ -0,0 +1,292 @@
package runtime
// This file is the end-to-end test for the headless / stdio run modes (US-020,
// #39). It drives RunHeadless over the real faux provider seam (no loop-internal
// mocking) and asserts the two output contracts — PrintMode's final text and
// StreamJSONMode's line-delimited JSON events — plus the success/failure signal
// that the CLI maps to a process exit code.
import (
"bufio"
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/provider"
)
// TestRunHeadlessPrintMode runs a text→tool→text scenario through RunHeadless in
// PrintMode and asserts that only the final assistant text reaches the writer,
// terminated by a newline, and that the run reports success (nil error).
func TestRunHeadlessPrintMode(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{
toolCallTurn("call-1", "echo", `{"msg":"hi"}`), // turn 1: tool call
textTurn("final answer"), // turn 2: final text
},
}
cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
var out bytes.Buffer
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: PrintMode, Out: &out})
if err != nil {
t.Fatalf("RunHeadless print mode: unexpected error %v", err)
}
got := out.String()
if got != "final answer\n" {
t.Errorf("print mode output = %q, want %q", got, "final answer\n")
}
}
// TestRunHeadlessStreamJSON runs the same scenario in StreamJSONMode and asserts
// every line is a valid JSON object carrying a "type" discriminant, that the run
// is bracketed by agent_start/agent_end, and that a tool execution is reported —
// the machine-readable protocol a parent process consumes.
func TestRunHeadlessStreamJSON(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{
toolCallTurn("call-1", "echo", `{"msg":"hi"}`),
textTurn("done"),
},
}
cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
var out bytes.Buffer
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: StreamJSONMode, Out: &out}); err != nil {
t.Fatalf("RunHeadless stream-json: unexpected error %v", err)
}
var types []string
sc := bufio.NewScanner(&out)
for sc.Scan() {
line := sc.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var env map[string]any
if err := json.Unmarshal(line, &env); err != nil {
t.Fatalf("stream-json line is not valid JSON: %q (%v)", line, err)
}
typ, ok := env["type"].(string)
if !ok || typ == "" {
t.Errorf("stream-json line missing type discriminant: %q", line)
}
types = append(types, typ)
}
if len(types) == 0 {
t.Fatal("stream-json produced no event lines")
}
if types[0] != agentcore.EventAgentStart || types[len(types)-1] != agentcore.EventAgentEnd {
t.Errorf("stream must be bracketed by agent_start/agent_end, got %v", types)
}
if !contains(types, agentcore.EventToolExecutionEnd) {
t.Errorf("expected a tool_execution_end event, got %v", types)
}
}
// TestRunHeadlessStreamJSONSessionID verifies that when RunConfig.SessionID is
// set, the first stream-json event (agent_start) carries it under "sessionId",
// so a consumer can associate the run's output with a session and resume it
// later (mirrors pi/Claude Code). When SessionID is empty the key is omitted.
func TestRunHeadlessStreamJSONSessionID(t *testing.T) {
run := func(sessionID string) map[string]any {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{textTurn("done")},
}
cfg := newFauxRunCfg(p)
cfg.SessionID = sessionID
var out bytes.Buffer
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: StreamJSONMode, Out: &out}); err != nil {
t.Fatalf("RunHeadless stream-json: unexpected error %v", err)
}
sc := bufio.NewScanner(&out)
for sc.Scan() {
line := sc.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var env map[string]any
if err := json.Unmarshal(line, &env); err != nil {
t.Fatalf("stream-json line is not valid JSON: %q (%v)", line, err)
}
if env["type"] == agentcore.EventAgentStart {
return env
}
}
t.Fatal("no agent_start event found")
return nil
}
first := run("sess-123")
if got, ok := first["sessionId"].(string); !ok || got != "sess-123" {
t.Errorf("agent_start sessionId = %v, want %q", first["sessionId"], "sess-123")
}
none := run("")
if _, present := none["sessionId"]; present {
t.Errorf("agent_start must omit sessionId when SessionID is empty, got %v", none["sessionId"])
}
}
// TestRunHeadlessReportsFailure verifies that a run whose final assistant message
// carries stopReason=error surfaces as an ErrRunFailed, so the CLI maps it to a
// non-zero exit code.
func TestRunHeadlessReportsFailure(t *testing.T) {
errPartial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
errFinal := errPartial
errFinal.StopReason = agentcore.StopReasonError
errFinal.ErrorMessage = "boom"
p := &fauxProvider{
name: "faux",
turns: []fauxTurn{
{
provider.StreamStartEvent{Partial: errPartial},
provider.StreamDoneEvent{Message: errFinal},
},
},
}
cfg := newFauxRunCfg(p)
var out bytes.Buffer
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: PrintMode, Out: &out})
if err == nil {
t.Fatal("run ending in stopReason=error must return a non-nil error")
}
var failed *ErrRunFailed
if !as(err, &failed) {
t.Fatalf("error = %T (%v), want *ErrRunFailed", err, err)
}
if !strings.Contains(failed.Error(), "boom") {
t.Errorf("error message = %q, want it to mention the failure reason", failed.Error())
}
}
// TestRunHeadlessNilWriter guards the misconfiguration path.
func TestRunHeadlessNilWriter(t *testing.T) {
p := &fauxProvider{turns: []fauxTurn{textTurn("x")}}
cfg := newFauxRunCfg(p)
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Out: nil}); err == nil {
t.Fatal("nil output writer must be rejected")
}
}
// emitTool returns a tool that surfaces ev on the run stream via the run-level
// progress emitter the loop injects into ctx (WithProgressEmitter), then returns
// a trivial text result. This mirrors how a dispatched sub-agent surfaces a
// SubAgentProgressEvent up the parent stream.
func emitTool(name string, ev agentcore.AgentEvent) execTool {
return execTool{
name: name,
mode: agentcore.ToolExecutionParallel,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
if emit := agentcore.ProgressEmitterFromContext(ctx); emit != nil {
_ = emit(ctx, ev)
}
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
},
}
}
// TestRunHeadlessSubAgentProgressToStderr verifies the D-9 contract: a
// SubAgentProgressEvent emitted during the run is rendered as a human-readable
// line to the progress writer (stderr) and is NEVER serialised onto stdout —
// neither the final result text nor the stream-json envelope stream may contain
// it. The event is injected via a faux tool whose execution fires it on the
// run's event stream (the same seam the loop uses).
func TestRunHeadlessSubAgentProgressToStderr(t *testing.T) {
const desc = "investigate the parser"
const activity = "Editing"
run := func(mode HeadlessMode) (stdout, stderr string) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{
toolCallTurn("call-1", "task", `{"description":"investigate the parser"}`),
textTurn("done"),
},
}
// The tool emits a SubAgentProgressEvent onto the run stream, mimicking a
// dispatched sub-agent surfacing progress up the parent stream.
tool := emitTool("task", agentcore.SubAgentProgressEvent{
ToolCallID: "call-1",
Description: desc,
Activity: activity,
})
cfg := newFauxRunCfg(p, tool)
var out, prog bytes.Buffer
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: mode, Out: &out, Progress: &prog}); err != nil {
t.Fatalf("RunHeadless: unexpected error %v", err)
}
return out.String(), prog.String()
}
for _, mode := range []struct {
name string
mode HeadlessMode
}{{"print", PrintMode}, {"stream-json", StreamJSONMode}} {
t.Run(mode.name, func(t *testing.T) {
stdout, stderr := run(mode.mode)
// (a) stderr carries the progress line with description + activity.
if !strings.Contains(stderr, desc) || !strings.Contains(stderr, activity) {
t.Errorf("stderr = %q, want it to contain description %q and activity %q", stderr, desc, activity)
}
// (b) stdout must not contain the progress event in any form.
if strings.Contains(stdout, "subagent_progress") {
t.Errorf("stdout must not contain the subagent_progress envelope, got %q", stdout)
}
if strings.Contains(stdout, desc) {
t.Errorf("stdout must not leak the progress description, got %q", stdout)
}
})
}
}
// TestWriteProgressLineEmptyDescription verifies the line degrades gracefully to
// the activity alone when the task supplied no description.
func TestWriteProgressLineEmptyDescription(t *testing.T) {
var buf bytes.Buffer
writeProgressLine(&buf, agentcore.SubAgentProgressEvent{Activity: "Thinking"})
got := buf.String()
if !strings.Contains(got, "Thinking") {
t.Errorf("line = %q, want it to contain the activity", got)
}
if strings.Contains(got, "·") {
t.Errorf("line = %q, want no separator when description is empty", got)
}
}
// contains reports whether s contains v.
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// as is a tiny errors.As shim kept local to avoid an extra import in a test that
// only ever unwraps one level.
func as(err error, target **ErrRunFailed) bool {
if e, ok := err.(*ErrRunFailed); ok {
*target = e
return true
}
return false
}
+503
View File
@@ -0,0 +1,503 @@
// This file implements pi's two-layer agent loop (US-006, FR-1). It strings
// together streaming assistant responses, batch tool execution, and the loop's
// six hooks with control flow kept faithful to pi's runLoop:
//
// - Inner loop: one turn = stream an assistant response → execute its tool
// calls → feed the results back, repeating until an assistant message has no
// tool calls (a natural turn end).
// - Outer loop: after the inner loop settles, pull getFollowUpMessages; if any
// are returned they become the next pending input and the inner loop runs
// again, otherwise the run ends.
//
// Per-turn hooks after each turn_end: getSteeringMessages (pulled after tool
// execution and injected before the next turn), prepareNextTurn (may swap
// context / model / thinkingLevel), shouldStopAfterTurn (true ⇒ agent_end +
// exit). Two stop reasons are handled specially: length (the response was
// truncated by the token cap) fails every tool call so the model resends
// (failToolCallsFromTruncatedMessage); error / aborted end the run immediately.
//
// agentLoop starts a fresh run from a prompt already appended to the context.
package runtime
import (
"context"
"fmt"
"os"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/provider"
)
// nowMillis returns the current Unix time in milliseconds, the timestamp unit
// used for CompactionMessage checkpoints.
func nowMillis() int64 { return time.Now().UnixMilli() }
// TurnUpdate is the optional result of PrepareNextTurn: any non-nil field
// replaces the corresponding piece of loop state before the next turn. It lets
// a caller swap the trimmed context, system prompt, tool set, model, or
// thinking level between turns (FR-6).
type TurnUpdate struct {
Messages *agentcore.MessageList
SystemPrompt *string
Tools *[]agentcore.AgentTool
Model *string
ThinkingLevel *agentcore.ThinkingLevel
}
// StopDecision is the result of the OnStop seam. Block=true prevents the run
// from ending; Guidance, when non-empty, is appended as a user-role message to
// steer the forced continuation (the Stop / SubagentStop hook's reason). The
// zero value (Block=false) lets the run end.
type StopDecision struct {
Block bool
Guidance string
}
// RunConfig is the full configuration for a loop run: the per-turn streaming
// config (embedded LoopConfig), the batch tool-execution config, and the four
// loop-level hooks. Every hook is optional (nil = default behavior).
type RunConfig struct {
LoopConfig
// Batch holds the tool registry and the prepare/before/after hooks used to
// execute each assistant message's tool calls.
Batch agenttool.BatchConfig
// GetFollowUpMessages is consulted after the inner loop settles (an assistant
// message with no tool calls). Returning messages continues the outer loop
// with them as the next input; returning none ends the run (FR-9).
GetFollowUpMessages func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage
// GetSteeringMessages is pulled after each turn's tool execution and injected
// before the next turn (pi per-turn semantics, FR-8).
GetSteeringMessages func(ctx context.Context) []agentcore.AgentMessage
// PrepareNextTurn runs after each turn_end and may swap context / model /
// thinkingLevel for the next turn (FR-6).
PrepareNextTurn func(ctx context.Context, agentCtx *agentcore.AgentContext) *TurnUpdate
// ShouldStopAfterTurn runs after each turn_end; true ends the run with an
// agent_end event (FR-7).
ShouldStopAfterTurn func(ctx context.Context, agentCtx *agentcore.AgentContext) bool
// OnStop, when set, is consulted right before the run would end naturally (no
// tool calls and no follow-up messages). Returning a decision with Block=true
// keeps the loop running: any Guidance is appended as a user-role message to
// steer the continued run (Stop / SubagentStop hooks, US-008/009, FR-10). The
// seam carries no loop-protection itself — the caller's decorator owns the
// consecutive-block counter and the FR-12 force-stop limit, so an ill-behaved
// hook cannot loop forever. nil or a non-blocking decision lets the run end.
OnStop func(ctx context.Context, agentCtx *agentcore.AgentContext) *StopDecision
// Reminders holds the per-turn system-reminder providers (US-002, FR-1/FR-2).
// When non-empty, ephemeral <system-reminder> messages are injected into each
// turn's LLM request through the existing TransformContext seam, so they never
// enter the persisted history. nil / empty = no injection.
Reminders *ReminderRegistry
// EventBuffer is the buffer size of the emitted EventStream. 0 gives fully
// synchronous back-pressure (matching pi's awaited emit).
EventBuffer int
// SessionID, when set, is carried in the run's agent_start event so a
// stream-json consumer sees the backing session id in the first event and can
// resume the run later (mirrors pi/Claude Code). It is also the session key
// under which auto-compaction checkpoints are persisted (see MemoryRoot).
SessionID string
// MemoryRoot, when non-empty (together with SessionID), enables checkpoint
// persistence for the "infinite context" feature (#480/#481): after a
// successful auto-compaction the collapsed prefix's summary is written as a
// checkpoint under <MemoryRoot>/sessions/<SessionID>/checkpoint.md so a later
// run can reload it instead of replaying the whole transcript. It is left ""
// when persistent memory is disabled (memory.enabled=false), which fully
// disables checkpoint writing. A checkpoint write failure is non-fatal.
MemoryRoot string
}
// LoopEventStream is the stream returned by the loop entry points: it carries
// AgentEvents and yields the messages newly produced during the run.
type LoopEventStream = agentcore.EventStream[agentcore.AgentEvent, []agentcore.AgentMessage]
// agentLoop starts a fresh run. The caller has already appended the initiating
// user message(s) to agentCtx.Messages. It returns immediately with an
// EventStream; a producer goroutine drives the loop and closes the stream when
// the run ends.
func agentLoop(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig) *LoopEventStream {
stream := agentcore.NewEventStream[agentcore.AgentEvent, []agentcore.AgentMessage](cfg.EventBuffer)
go runLoop(ctx, agentCtx, cfg, stream)
return stream
}
// StartRun is the exported entry point for a fresh run, used by out-of-package
// drivers (the interactive REPL, US-022). It is a thin wrapper over agentLoop so
// the loop internals stay unexported while callers outside the package can
// still launch a run and consume its event stream.
func StartRun(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig) *LoopEventStream {
return agentLoop(ctx, agentCtx, cfg)
}
// runLoop is the producer: it drives the two-layer loop, emitting events onto
// stream and setting the stream result to the messages produced during the run.
func runLoop(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig, stream *LoopEventStream) {
// Wire per-turn system-reminder injection (US-002) onto the TransformContext
// seam. Reminders are appended to the request-shaped copy only, so they stay
// ephemeral: never written back to agentCtx.Messages, never persisted, never
// swept into a compaction summary.
if !cfg.Reminders.Empty() {
cfg.TransformContext = cfg.Reminders.wrapTransform(cfg.TransformContext)
}
startIdx := len(agentCtx.Messages)
// tel accumulates structured telemetry (turn count, per-tool durations,
// truncation count, compaction count, latest context-utilization ratio) from
// the events emitted below, surfaced as a TelemetryEvent at run end.
tel := newTelemetry()
// newMessages returns the messages appended since the run began.
newMessages := func() []agentcore.AgentMessage {
if len(agentCtx.Messages) <= startIdx {
return nil
}
out := make([]agentcore.AgentMessage, len(agentCtx.Messages)-startIdx)
copy(out, agentCtx.Messages[startIdx:])
return out
}
emit := func(ev agentcore.AgentEvent) error {
tel.observe(ev)
return stream.Emit(ctx, ev)
}
// emitFrom wraps the raw stream.Emit callback handed to streamAssistantResponse
// and ExecuteToolCalls so telemetry observes those events (message_* and
// tool_execution_*) too, without changing their signatures.
emitFrom := func(c context.Context, ev agentcore.AgentEvent) error {
tel.observe(ev)
return stream.Emit(c, ev)
}
// finish emits the telemetry summary then agent_end (unless suppressed by a
// prior emit error), records the run result, and closes the stream exactly
// once. Telemetry is emitted first so a consumer sees the run's structured
// metrics immediately before the terminal event.
finish := func() {
_ = emit(tel.summary())
msgs := newMessages()
_ = emit(agentcore.AgentEndEvent{Messages: msgs})
stream.SetResult(msgs)
stream.Close()
}
if err := emit(agentcore.AgentStartEvent{SessionID: cfg.SessionID}); err != nil {
finish()
return
}
for { // outer loop: pending / follow-up messages
for { // inner loop: turns until no tool calls
if err := emit(agentcore.TurnStartEvent{}); err != nil {
finish()
return
}
assistant, err := streamAssistantResponse(ctx, agentCtx, cfg.LoopConfig, emitFrom)
if err != nil {
// emit was cancelled mid-stream; end the run.
finish()
return
}
switch assistant.StopReason {
case agentcore.StopReasonLength:
// Truncated by the token cap: fail every tool call so the model
// resends, then continue feeding back.
toolResults := failToolCallsFromTruncatedMessage(agentCtx, assistant)
if err := emit(agentcore.TurnEndEvent{Message: assistant, ToolResults: toolResults}); err != nil {
finish()
return
}
if afterTurn(ctx, agentCtx, &cfg, true, emit, tel) {
finish()
return
}
continue
case agentcore.StopReasonError, agentcore.StopReasonAborted:
// Terminal failure: emit the turn end and stop.
_ = emit(agentcore.TurnEndEvent{Message: assistant})
finish()
return
}
calls := toAgentToolCalls(assistant.ToolCalls())
if len(calls) == 0 {
// Natural turn end: no tools to run.
if err := emit(agentcore.TurnEndEvent{Message: assistant}); err != nil {
finish()
return
}
if afterTurn(ctx, agentCtx, &cfg, false, emit, tel) {
finish()
return
}
break // exit inner loop → consult follow-up messages
}
// Inject the run-level emitter into the context so tools (notably the
// generic task tool) can retrieve it via ProgressEmitterFromContext and
// surface a dispatched sub-agent's progress up this parent event stream.
// emitFrom feeds the parent stream and is run-scoped, so a child's
// SubAgentProgressEvent lands on the right run's stream.
toolCtx := agentcore.WithProgressEmitter(ctx, emitFrom)
toolResults, allTerminate := agenttool.ExecuteToolCalls(toolCtx, cfg.Batch, calls, emitFrom)
for _, tr := range toolResults {
agentCtx.Messages = append(agentCtx.Messages, tr)
}
if err := emit(agentcore.TurnEndEvent{Message: assistant, ToolResults: toolResults}); err != nil {
finish()
return
}
if allTerminate {
// Every tool asked to terminate the run.
finish()
return
}
if afterTurn(ctx, agentCtx, &cfg, true, emit, tel) {
finish()
return
}
// Feed the tool results back into the next turn.
}
// Inner loop settled: consult follow-up messages.
if cfg.GetFollowUpMessages != nil {
if follow := cfg.GetFollowUpMessages(ctx, agentCtx); len(follow) > 0 {
agentCtx.Messages = append(agentCtx.Messages, follow...)
continue // outer loop with the follow-ups as new input
}
}
// Stop hook: the run is about to end naturally. A hook may block the end
// and force a continuation, feeding its guidance back as the next input
// (US-008/009, FR-10). The consecutive-block counter and force-stop limit
// (FR-12) live in the decorator behind OnStop, so this seam stays simple.
if cfg.OnStop != nil {
if dec := cfg.OnStop(ctx, agentCtx); dec != nil && dec.Block {
if dec.Guidance != "" {
agentCtx.Messages = append(agentCtx.Messages, agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(dec.Guidance)},
})
}
continue // outer loop: keep the run alive
}
}
break
}
finish()
}
// afterTurn runs the per-turn hooks after a turn_end. When hadToolExecution is
// true it first pulls getSteeringMessages and injects them before the next turn
// (pi per-turn semantics). It then applies prepareNextTurn, runs auto-compaction
// when the context has outgrown its window, and finally consults
// shouldStopAfterTurn, returning true when the run should end.
func afterTurn(ctx context.Context, agentCtx *agentcore.AgentContext, cfg *RunConfig, hadToolExecution bool, emit func(agentcore.AgentEvent) error, tel *telemetry) (stop bool) {
if hadToolExecution && cfg.GetSteeringMessages != nil {
if steer := cfg.GetSteeringMessages(ctx); len(steer) > 0 {
agentCtx.Messages = append(agentCtx.Messages, steer...)
}
}
if cfg.PrepareNextTurn != nil {
if upd := cfg.PrepareNextTurn(ctx, agentCtx); upd != nil {
applyTurnUpdate(agentCtx, cfg, upd)
}
}
maybeAutoCompact(ctx, agentCtx, cfg, emit, tel)
// Record the latest context-utilization ratio once the turn has settled (after
// any compaction), so the telemetry summary reports the current used/window
// figure. This runs even when auto-compaction is disabled so utilization is
// still observable whenever the context window is known.
if tel != nil && cfg.ContextWindow > 0 {
tokens := compaction.EstimateContextTokens(agentCtx.Messages).Tokens
tel.recordContext(tokens, cfg.ContextWindow)
}
if cfg.ShouldStopAfterTurn != nil {
return cfg.ShouldStopAfterTurn(ctx, agentCtx)
}
return false
}
// maybeAutoCompact checks whether the context has outgrown its usable window and,
// if so, compacts it in place and emits a CompactionEvent. Compaction is a no-op
// when disabled, when the context window is unknown (<= 0), or when usage is
// under threshold. A compaction failure is non-fatal: the original context is
// preserved and a CompactionEvent carrying ErrorMessage is emitted so the failure
// is observable without aborting the run (US-004).
func maybeAutoCompact(ctx context.Context, agentCtx *agentcore.AgentContext, cfg *RunConfig, emit func(agentcore.AgentEvent) error, tel *telemetry) {
if !cfg.Compaction.Enabled || cfg.ContextWindow <= 0 {
return
}
before := compaction.EstimateContextTokens(agentCtx.Messages).Tokens
// Record pre-compaction utilization so the ratio reflects the peak that
// triggered (or nearly triggered) compaction even when the summary is read
// mid-run. afterTurn overwrites it with the post-settle figure.
if tel != nil {
tel.recordContext(before, cfg.ContextWindow)
}
if !compaction.ShouldCompact(before, cfg.ContextWindow, cfg.Compaction) {
return
}
// Signal the start so a front-end can show an in-progress indicator while the
// summarization request (an LLM call that blocks the loop) is in flight.
_ = emit(agentcore.CompactionStartEvent{Reason: "threshold", TokensBefore: before})
res, err := runCompaction(ctx, agentCtx.Messages, cfg)
kept := len(agentCtx.Messages)
if err != nil {
_ = emit(agentcore.CompactionEvent{
Reason: "threshold",
TokensBefore: before,
TokensAfter: before,
KeptCount: kept,
ErrorMessage: err.Error(),
})
return
}
if res == nil {
// Nothing to summarize (cut point left no prefix); leave context as-is.
return
}
// Persist a checkpoint of the collapsed prefix before rewriting the context so
// a later run can reload it (infinite context, #480/#481). It reuses the
// summary compaction just produced — no extra LLM call — and is best-effort:
// a write failure is logged and the run continues on the compacted context.
writeCompactionCheckpoint(ctx, agentCtx.Messages, res, cfg)
now := nowMillis()
rebuilt := res.RebuildContext(agentCtx.Messages, now)
summarized := len(agentCtx.Messages) - (len(rebuilt) - 1)
agentCtx.Messages = rebuilt
after := compaction.EstimateContextTokens(rebuilt).Tokens
_ = emit(agentcore.CompactionEvent{
Reason: "threshold",
TokensBefore: before,
TokensAfter: after,
SummarizedCount: summarized,
KeptCount: len(rebuilt) - 1,
})
}
// runCompaction invokes compaction.Compact with the loop's summarization config,
// falling back to the primary Stream/Model when the summary-specific fields are
// unset. Compact derives the cut point from settings.KeepRecentTokens.
func runCompaction(ctx context.Context, msgs agentcore.MessageList, cfg *RunConfig) (*compaction.CompactionResult, error) {
stream := cfg.SummaryStream
if stream == nil {
stream = cfg.Stream
}
model := cfg.SummaryModel
if model.ID == "" {
model = provider.Model{Provider: cfg.Provider, ID: cfg.Model, ContextWindow: cfg.ContextWindow}
}
// Resolve the API key the same way the primary turn does (dynamic key wins,
// static APIKey is the fallback) so the summarization stream authenticates
// against auth-requiring providers instead of failing with "missing API key".
key := cfg.APIKey
if cfg.GetAPIKey != nil {
if dyn := cfg.GetAPIKey(ctx, cfg.Provider); dyn != "" {
key = dyn
}
}
scfg := provider.StreamConfig{APIKey: key, ThinkingLevel: cfg.ThinkingLevel}
return compaction.Compact(ctx, stream, model, msgs, cfg.Compaction, -1, nil, "", scfg)
}
// writeCompactionCheckpoint persists the just-produced compaction summary as a
// session checkpoint so a later run can reload the collapsed prefix instead of
// replaying it (#480/#481). It is a no-op unless checkpoint persistence is wired
// (MemoryRoot and SessionID both set) — which is how memory.enabled=false keeps
// the whole subsystem inert. It reuses res.Summary (no extra summarization call)
// via BuildCheckpoint, tagging the checkpoint with the compaction cut point as
// its watermark. All failures are non-fatal: they are logged to stderr and the
// run continues on the compacted context (WriteCheckpoint's log-and-continue
// contract).
func writeCompactionCheckpoint(ctx context.Context, msgs agentcore.MessageList, res *compaction.CompactionResult, cfg *RunConfig) {
if cfg.MemoryRoot == "" || cfg.SessionID == "" || res == nil {
return
}
watermark := res.FirstKeptIndex
if watermark < 0 {
watermark = 0
}
if watermark > len(msgs) {
watermark = len(msgs)
}
// summarize returns the summary the compaction already computed, so
// BuildCheckpoint records an honest CoveredMessages count without a second
// LLM round-trip.
summarize := func(context.Context, []agentcore.Message) (string, error) {
return res.Summary, nil
}
cp, err := BuildCheckpoint(ctx, msgs[:watermark], watermark, time.Now(), summarize)
if err != nil {
fmt.Fprintf(os.Stderr, "pigo: checkpoint: build for session %s: %v\n", cfg.SessionID, err)
return
}
if err := WriteCheckpoint(cfg.SessionID, cfg.MemoryRoot, cp); err != nil {
fmt.Fprintf(os.Stderr, "pigo: checkpoint: write for session %s: %v\n", cfg.SessionID, err)
}
}
// applyTurnUpdate applies a non-nil TurnUpdate to the mutable loop state: any
// set field replaces the current context / config value for the next turn.
func applyTurnUpdate(agentCtx *agentcore.AgentContext, cfg *RunConfig, upd *TurnUpdate) {
if upd.Messages != nil {
agentCtx.Messages = *upd.Messages
}
if upd.SystemPrompt != nil {
agentCtx.SystemPrompt = *upd.SystemPrompt
}
if upd.Tools != nil {
agentCtx.Tools = *upd.Tools
}
if upd.Model != nil {
cfg.Model = *upd.Model
}
if upd.ThinkingLevel != nil {
cfg.ThinkingLevel = *upd.ThinkingLevel
}
}
// failToolCallsFromTruncatedMessage produces an error tool-result message for
// every tool call in a truncated (stopReason=length) assistant message, telling
// the model the response was cut off and to resend. The results are appended to
// the context and returned. Mirrors pi's failToolCallsFromTruncatedMessage.
func failToolCallsFromTruncatedMessage(agentCtx *agentcore.AgentContext, assistant agentcore.AssistantMessage) []agentcore.ToolResultMessage {
calls := assistant.ToolCalls()
if len(calls) == 0 {
return nil
}
results := make([]agentcore.ToolResultMessage, 0, len(calls))
for _, c := range calls {
results = append(results, agentcore.ToolResultMessage{
RoleField: agentcore.RoleToolResult,
ToolCallID: c.ID,
ToolName: c.Name,
Content: agentcore.ContentList{agentcore.NewTextContent(
"The previous response was truncated because it hit the output token limit, " +
"so this tool call was not executed. Please send a shorter response and retry.")},
IsError: true,
})
}
for _, r := range results {
agentCtx.Messages = append(agentCtx.Messages, r)
}
return results
}
// toAgentToolCalls converts the assistant message's ToolCallContent blocks into
// the loop-level AgentToolCall view executeToolCalls consumes.
func toAgentToolCalls(blocks []agentcore.ToolCallContent) []agentcore.AgentToolCall {
if len(blocks) == 0 {
return nil
}
calls := make([]agentcore.AgentToolCall, len(blocks))
for i, b := range blocks {
calls[i] = agentcore.AgentToolCall{ID: b.ID, Name: b.Name, Arguments: b.Arguments}
}
return calls
}
+63
View File
@@ -0,0 +1,63 @@
package runtime
import (
"context"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// TestAgentLoopOnStopBlocksThenAllows: OnStop blocks the natural end twice
// (injecting guidance each time) then allows it, so the run takes three turns
// and the guidance messages land in the context.
func TestAgentLoopOnStopBlocksThenAllows(t *testing.T) {
cfg := newRunCfg(scriptedStream(nil)) // always a natural end_turn
blocks := 0
cfg.OnStop = func(ctx context.Context, agentCtx *agentcore.AgentContext) *StopDecision {
if blocks < 2 {
blocks++
return &StopDecision{Block: true, Guidance: "keep going"}
}
return nil
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, _ := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if got := countKind(kinds, agentcore.EventTurnStart); got != 3 {
t.Fatalf("expected 3 turns (2 forced continuations + final), got %d (%v)", got, kinds)
}
if kinds[len(kinds)-1] != agentcore.EventAgentEnd {
t.Fatalf("run must end with agent_end, got %v", kinds)
}
guidance := 0
for _, m := range agentCtx.Messages {
if um, ok := m.(agentcore.UserMessage); ok && len(um.Content) == 1 {
if tc, ok := um.Content[0].(agentcore.TextContent); ok && tc.Text == "keep going" {
guidance++
}
}
}
if guidance != 2 {
t.Fatalf("expected 2 injected guidance messages, got %d", guidance)
}
}
// TestAgentLoopOnStopNilEndsRun: a nil OnStop decision lets the run end after a
// single natural turn.
func TestAgentLoopOnStopNilEndsRun(t *testing.T) {
cfg := newRunCfg(scriptedStream(nil))
consulted := false
cfg.OnStop = func(ctx context.Context, agentCtx *agentcore.AgentContext) *StopDecision {
consulted = true
return nil
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, _ := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if !consulted {
t.Fatal("OnStop was never consulted")
}
if got := countKind(kinds, agentcore.EventTurnStart); got != 1 {
t.Fatalf("nil decision must end after one turn, got %d turns", got)
}
}
+286
View File
@@ -0,0 +1,286 @@
package runtime
import (
"context"
"encoding/json"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/provider"
)
// collectStream drains a LoopEventStream, returning the event types in order
// and the run result messages.
func collectStream(t *testing.T, s *LoopEventStream) ([]string, []agentcore.AgentMessage) {
t.Helper()
var kinds []string
for ev := range s.Events() {
kinds = append(kinds, ev.EventType())
}
msgs, err := s.Result(context.Background())
if err != nil {
t.Fatalf("stream result: %v", err)
}
return kinds, msgs
}
// oneToolAssistant builds an assistant message with a single tool call.
func oneToolAssistant(id, name string) agentcore.AssistantMessage {
return agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
StopReason: agentcore.StopReasonToolUse,
Content: agentcore.ContentList{agentcore.NewToolCallContent(id, name, json.RawMessage(`{}`))},
}
}
// scriptedStream returns a StreamFn that emits one StreamDoneEvent per call,
// consuming msgs in order. Extra calls beyond msgs emit a plain end_turn.
func scriptedStream(msgs []agentcore.AssistantMessage) provider.StreamFn {
i := 0
return func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
var msg agentcore.AssistantMessage
if i < len(msgs) {
msg = msgs[i]
} else {
msg = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn}
}
i++
s := provider.NewAssistantMessageEventStream(0)
go func() {
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: msg})
s.Close()
}()
return s, nil
}
}
func newRunCfg(stream provider.StreamFn, tools ...agentcore.AgentTool) RunConfig {
reg := agenttool.NewToolRegistry()
for _, tl := range tools {
_ = reg.Register(tl)
}
return RunConfig{
LoopConfig: LoopConfig{Model: "fake", Stream: stream},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}},
}
}
func TestAgentLoopNoToolCallsSingleTurn(t *testing.T) {
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}},
}))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
want := []string{agentcore.EventAgentStart, agentcore.EventTurnStart, agentcore.EventMessageEnd, agentcore.EventTurnEnd, agentcore.EventTelemetry, agentcore.EventAgentEnd}
assertEventKinds(t, kinds, want)
if len(msgs) != 1 {
t.Fatalf("run produced %d messages, want 1: %+v", len(msgs), msgs)
}
}
func TestAgentLoopInnerLoopFeedsToolResults(t *testing.T) {
// Turn 1: tool call. Turn 2: no tool call → inner loop ends.
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
oneToolAssistant("c1", "echo"),
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("done")}},
}), echoTool("echo", agentcore.ToolExecutionParallel, false))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
// Two turns; a tool executed in the first.
if countKind(kinds, agentcore.EventTurnStart) != 2 {
t.Errorf("expected 2 turns, got kinds %v", kinds)
}
if countKind(kinds, agentcore.EventToolExecutionEnd) != 1 {
t.Errorf("expected 1 tool execution, got kinds %v", kinds)
}
// Messages produced: assistant(tool) + toolResult + assistant(done) = 3.
if len(msgs) != 3 {
t.Fatalf("expected 3 new messages, got %d: %+v", len(msgs), msgs)
}
if _, ok := msgs[1].(agentcore.ToolResultMessage); !ok {
t.Errorf("expected message[1] to be a tool result, got %T", msgs[1])
}
}
func TestAgentLoopFollowUpMessagesContinue(t *testing.T) {
served := false
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("first")}},
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("second")}},
}))
cfg.GetFollowUpMessages = func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage {
if served {
return nil
}
served = true
return []agentcore.AgentMessage{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("more")}}}
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, _ := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if countKind(kinds, agentcore.EventTurnStart) != 2 {
t.Errorf("follow-up should drive a second turn, got kinds %v", kinds)
}
}
func TestAgentLoopShouldStopAfterTurn(t *testing.T) {
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
oneToolAssistant("c1", "echo"),
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn},
}), echoTool("echo", agentcore.ToolExecutionParallel, false))
cfg.ShouldStopAfterTurn = func(ctx context.Context, agentCtx *agentcore.AgentContext) bool { return true }
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, _ := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
// Stops after the first turn_end, so only one turn.
if countKind(kinds, agentcore.EventTurnStart) != 1 {
t.Errorf("shouldStopAfterTurn=true must stop after one turn, got %v", kinds)
}
if kinds[len(kinds)-1] != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end, got %v", kinds)
}
}
func TestAgentLoopSteeringInjected(t *testing.T) {
var injectedSeen bool
steer := agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("steer")}}
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
oneToolAssistant("c1", "echo"),
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn},
}), echoTool("echo", agentcore.ToolExecutionParallel, false))
pulled := false
cfg.GetSteeringMessages = func(ctx context.Context) []agentcore.AgentMessage {
if pulled {
return nil
}
pulled = true
return []agentcore.AgentMessage{steer}
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
for _, m := range agentCtx.Messages {
if um, ok := m.(agentcore.UserMessage); ok && len(um.Content) == 1 {
if tc, ok := um.Content[0].(agentcore.TextContent); ok && tc.Text == "steer" {
injectedSeen = true
}
}
}
if !injectedSeen {
t.Errorf("steering message was not injected into the context")
}
}
func TestAgentLoopPrepareNextTurnSwapsModel(t *testing.T) {
var seenModels []string
streamFn := func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
seenModels = append(seenModels, model)
var msg agentcore.AssistantMessage
if len(seenModels) == 1 {
msg = oneToolAssistant("c1", "echo")
} else {
msg = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn}
}
s := provider.NewAssistantMessageEventStream(0)
go func() { _ = s.Emit(ctx, provider.StreamDoneEvent{Message: msg}); s.Close() }()
return s, nil
}
cfg := newRunCfg(streamFn, echoTool("echo", agentcore.ToolExecutionParallel, false))
newModel := "swapped-model"
cfg.PrepareNextTurn = func(ctx context.Context, agentCtx *agentcore.AgentContext) *TurnUpdate {
return &TurnUpdate{Model: &newModel}
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if len(seenModels) != 2 || seenModels[1] != newModel {
t.Errorf("prepareNextTurn should swap model to %q, saw %v", newModel, seenModels)
}
}
func TestAgentLoopLengthFailsToolCalls(t *testing.T) {
// Turn 1: tool call but truncated (length). Turn 2: end.
truncated := oneToolAssistant("c1", "echo")
truncated.StopReason = agentcore.StopReasonLength
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
truncated,
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn},
}), echoTool("echo", agentcore.ToolExecutionParallel, false))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
// The tool must NOT have executed (truncated → failed instead).
if countKind(kinds, agentcore.EventToolExecutionEnd) != 0 {
t.Errorf("truncated message must not execute tools, got %v", kinds)
}
// A failed tool result must have been synthesized.
var foundFail bool
for _, m := range msgs {
if tr, ok := m.(agentcore.ToolResultMessage); ok && tr.IsError && tr.ToolCallID == "c1" {
foundFail = true
}
}
if !foundFail {
t.Errorf("expected a synthesized failed tool result for the truncated call")
}
}
func TestAgentLoopErrorStopEndsRun(t *testing.T) {
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonError, ErrorMessage: "boom"},
}))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, _ := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if countKind(kinds, agentcore.EventTurnStart) != 1 {
t.Errorf("error stop must end after one turn, got %v", kinds)
}
if kinds[len(kinds)-1] != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end, got %v", kinds)
}
}
func TestAgentLoopAllTerminateStopsRun(t *testing.T) {
term := true
termTool := execTool{
name: "quit",
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("bye")}, Terminate: &term}, nil
},
}
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
oneToolAssistant("c1", "quit"),
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn}, // should never be reached
}), termTool)
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
kinds, _ := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
if countKind(kinds, agentcore.EventTurnStart) != 1 {
t.Errorf("terminate must end the run after one turn, got %v", kinds)
}
}
func assertEventKinds(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("event kinds = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("event[%d] = %q, want %q (full %v)", i, got[i], want[i], got)
}
}
}
func countKind(kinds []string, want string) int {
n := 0
for _, k := range kinds {
if k == want {
n++
}
}
return n
}
+185
View File
@@ -0,0 +1,185 @@
package runtime
import (
"context"
"sort"
"strings"
"sync"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/memory"
)
// defaultMemoryReminderMaxChars is the per-turn character budget for the
// injected memory body. It is deliberately modest so relevant memory context
// does not crowd out the live conversation in the model's window.
const defaultMemoryReminderMaxChars = 600
// defaultMemoryReminderLimit is the number of search hits fetched per turn
// before budget trimming.
const defaultMemoryReminderLimit = 5
// MemoryReminderProvider injects relevant persistent memory as ephemeral
// background context on each turn (issue #478). It derives a query from the
// most recent user message, runs a BM25 search over the memory store, and
// surfaces the top-ranked snippets — budget-capped and deduped so identical
// context is never repeated turn after turn.
//
// The returned body is RAW plain text; the reminder registry wraps it in
// <system-reminder> tags and injects it only into the per-turn LLM request, so
// it never enters persisted history.
type MemoryReminderProvider struct {
// Store is the persistent memory database. When nil the provider never
// fires.
Store *memory.Store
// MaxChars caps the injected body length. <=0 uses
// defaultMemoryReminderMaxChars.
MaxChars int
// Limit caps the number of search hits considered. <=0 uses
// defaultMemoryReminderLimit.
Limit int
// Scope and ScopeID, when non-empty, focus the search on a single memory
// scope (e.g. the current project id) via SearchOptions.
Scope string
ScopeID string
// mu guards lastBody so Reminder is safe to call across turns.
mu sync.Mutex
lastBody string
}
// Name implements ReminderProvider.
func (p *MemoryReminderProvider) Name() string { return "memory" }
// Reminder implements ReminderProvider. It fires when the latest user message
// yields a search query that matches stored memory, returning a concise,
// budget-capped body of ranked snippets. It stays silent when the store is nil,
// there is no user text to search on, the search errors or returns nothing, or
// the produced body is identical to the one injected on the previous firing
// turn (dedupe).
func (p *MemoryReminderProvider) Reminder(ctx context.Context, msgs agentcore.MessageList) (string, bool) {
if p == nil || p.Store == nil {
return "", false
}
query := latestUserText(msgs)
if strings.TrimSpace(query) == "" {
return "", false
}
limit := p.Limit
if limit <= 0 {
limit = defaultMemoryReminderLimit
}
results, err := p.Store.Search(query, memory.SearchOptions{
Scope: p.Scope,
ScopeID: p.ScopeID,
Limit: limit,
ReconcileFirst: true,
})
if err != nil || len(results) == 0 {
return "", false
}
body := p.buildBody(results)
if body == "" {
return "", false
}
// Dedupe: never re-inject the identical body on a subsequent firing turn.
p.mu.Lock()
defer p.mu.Unlock()
if body == p.lastBody {
return "", false
}
p.lastBody = body
return body, true
}
// buildBody renders the ranked results into a concise, budget-capped body.
// MEMORY.md index files (and free-type entries) are ordered first as they are
// the most useful high-level context.
func (p *MemoryReminderProvider) buildBody(results []memory.SearchResult) string {
maxChars := p.MaxChars
if maxChars <= 0 {
maxChars = defaultMemoryReminderMaxChars
}
// Stable sort so index-type / MEMORY.md hits float to the top while
// preserving the underlying BM25 order among equals.
ordered := make([]memory.SearchResult, len(results))
copy(ordered, results)
sort.SliceStable(ordered, func(i, j int) bool {
return memoryRank(ordered[i]) < memoryRank(ordered[j])
})
const heading = "Relevant memory:"
var b strings.Builder
b.WriteString(heading)
for _, r := range ordered {
snippet := strings.TrimSpace(strings.ReplaceAll(r.Snippet, "\n", " "))
if snippet == "" {
continue
}
line := "\n- " + r.Path + ": " + snippet
// Enforce the budget: stop before exceeding maxChars, and never emit a
// heading-only body.
if b.Len()+len(line) > maxChars {
if b.Len() > len(heading) {
break
}
// The very first line already overflows: hard-truncate it so we
// still surface something within budget.
room := maxChars - b.Len()
if room <= 0 {
break
}
if room < len(line) {
line = line[:room]
}
b.WriteString(line)
break
}
b.WriteString(line)
}
if b.Len() <= len(heading) {
return ""
}
return b.String()
}
// memoryRank returns a sort key that floats MEMORY.md index files and free-type
// entries to the front (rank 0) ahead of everything else (rank 1).
func memoryRank(r memory.SearchResult) int {
if strings.HasSuffix(r.Path, "MEMORY.md") || r.Type == memory.TypeFree {
return 0
}
return 1
}
// latestUserText returns the flattened text of the most recent user message in
// msgs, or "" if there is none. Reminder messages are user-role too, but they
// carry the <system-reminder> preamble; those are skipped so the search query
// reflects the genuine user request rather than previously injected context.
func latestUserText(msgs agentcore.MessageList) string {
for i := len(msgs) - 1; i >= 0; i-- {
um, ok := msgs[i].(agentcore.UserMessage)
if !ok {
continue
}
text := agentcore.ContentToText(um.Content)
if strings.Contains(text, "<system-reminder>") {
continue
}
if strings.TrimSpace(text) == "" {
continue
}
return text
}
return ""
}
@@ -0,0 +1,150 @@
package runtime
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/memory"
)
// openMemoryStore opens a memory.Store over a temp DB + root and writes the
// given memory files (path segments relative to root -> body), returning the
// store. Reconcile is left to the provider's ReconcileFirst.
func openMemoryStore(t *testing.T, files map[string]string) *memory.Store {
t.Helper()
base := t.TempDir()
root := filepath.Join(base, "mimo")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir root: %v", err)
}
for rel, body := range files {
full := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir for %q: %v", full, err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatalf("write %q: %v", full, err)
}
}
st, err := memory.Open(filepath.Join(base, "memory.db"), root, "")
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
// userMsgs builds a MessageList with a single user text message.
func userMsgs(text string) agentcore.MessageList {
return agentcore.MessageList{
agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
},
}
}
func TestMemoryReminderInjectsMatchingSnippet(t *testing.T) {
st := openMemoryStore(t, map[string]string{
filepath.Join("projects", "proj1", "notes", "auth.md"): "permission deadlock encountered during checkpoint save then retry succeeded",
filepath.Join("global", "user", "u1.md"): "unrelated grocery shopping list",
})
p := &MemoryReminderProvider{Store: st, MaxChars: 400}
body, ok := p.Reminder(context.Background(), userMsgs("how do I handle the permission deadlock?"))
if !ok {
t.Fatalf("expected a memory reminder to fire, got ok=false")
}
if !strings.Contains(body, "Relevant memory:") {
t.Errorf("body missing heading: %q", body)
}
if !strings.Contains(body, "permission") {
t.Errorf("body missing the matching snippet text: %q", body)
}
if len(body) > 400 {
t.Errorf("body exceeds MaxChars budget: len=%d body=%q", len(body), body)
}
// Second identical call dedupes.
if _, ok := p.Reminder(context.Background(), userMsgs("how do I handle the permission deadlock?")); ok {
t.Errorf("identical follow-up call should dedupe to ok=false")
}
}
func TestMemoryReminderRespectsMaxChars(t *testing.T) {
long := strings.Repeat("permission deadlock retry ", 40)
st := openMemoryStore(t, map[string]string{
filepath.Join("projects", "proj1", "notes", "big.md"): long,
})
p := &MemoryReminderProvider{Store: st, MaxChars: 120}
body, ok := p.Reminder(context.Background(), userMsgs("permission deadlock"))
if !ok {
t.Fatalf("expected a memory reminder to fire")
}
if len(body) > 120 {
t.Errorf("body exceeds MaxChars=120: len=%d", len(body))
}
}
func TestMemoryReminderNoUserMessage(t *testing.T) {
st := openMemoryStore(t, map[string]string{
filepath.Join("global", "user", "u1.md"): "permission deadlock note",
})
p := &MemoryReminderProvider{Store: st}
// Empty message list.
if _, ok := p.Reminder(context.Background(), nil); ok {
t.Errorf("empty message list must not fire")
}
// User message with no text content.
blank := agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}
if _, ok := p.Reminder(context.Background(), blank); ok {
t.Errorf("empty user text must not fire")
}
}
func TestMemoryReminderNoMatch(t *testing.T) {
st := openMemoryStore(t, map[string]string{
filepath.Join("global", "user", "u1.md"): "grocery shopping list milk eggs",
})
p := &MemoryReminderProvider{Store: st}
if _, ok := p.Reminder(context.Background(), userMsgs("kubernetes ingress controller crash")); ok {
t.Errorf("no matching memory should not fire")
}
}
func TestMemoryReminderNilStore(t *testing.T) {
p := &MemoryReminderProvider{}
if _, ok := p.Reminder(context.Background(), userMsgs("anything")); ok {
t.Errorf("nil store must not fire")
}
var np *MemoryReminderProvider
if _, ok := np.Reminder(context.Background(), userMsgs("anything")); ok {
t.Errorf("nil provider must not fire")
}
}
func TestMemoryReminderMemoryMdFirst(t *testing.T) {
st := openMemoryStore(t, map[string]string{
filepath.Join("projects", "proj1", "notes", "detail.md"): "permission deadlock detail note here",
filepath.Join("projects", "proj1", "MEMORY.md"): "permission deadlock index overview",
})
p := &MemoryReminderProvider{Store: st, MaxChars: 800}
body, ok := p.Reminder(context.Background(), userMsgs("permission deadlock"))
if !ok {
t.Fatalf("expected reminder to fire")
}
idxMem := strings.Index(body, "MEMORY.md")
idxDetail := strings.Index(body, "detail.md")
if idxMem == -1 || idxDetail == -1 {
t.Fatalf("expected both files in body: %q", body)
}
if idxMem > idxDetail {
t.Errorf("MEMORY.md should sort before other results, got body: %q", body)
}
}
+638
View File
@@ -0,0 +1,638 @@
package runtime
// Tests for sub-agent orchestration, skills, and slash-commands (US-027/028/029,
// #45). The sub-agent integration test drives the flagship parent→child→parent
// path through the faux provider seam (mirrors faux_provider_test.go): the parent
// loop calls a sub-agent tool, the child runs its own scripted loop, and the
// child's final text is fed back as the parent's tool result. Skills and
// slash-commands get focused load/parse unit tests.
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/provider"
)
// TestSubAgentParentChildParent is acceptance-critical: a parent agent loop
// delegates to a sub-agent via a tool call, the child runs an independent loop
// with its own context and provider, and the child's final assistant text is
// returned to the parent as the tool result (parent->child->parent). Both loops are driven by
// faux providers over the real StreamFnFromProvider seam.
func TestSubAgentParentChildParent(t *testing.T) {
// Child provider: a single scripted turn producing the delegated answer.
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{textTurn("child result: 42")},
}
// The sub-agent tool spawns a child loop over its own context + provider.
sub := NewSubAgentTool(SubAgentSpec{
Name: "researcher",
Description: "delegate research to a fresh sub-agent",
SystemPrompt: "you are a researcher",
Tools: nil,
NewRunConfig: func() RunConfig {
return RunConfig{
LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}},
}
},
})
// Parent provider: turn 1 calls the sub-agent, turn 2 (after the tool result
// is fed back) produces the final answer.
parent := &fauxProvider{
name: "faux-parent",
models: []provider.Model{{Provider: "faux-parent", ID: "parent"}},
turns: []fauxTurn{
toolCallTurn("call-sub", "researcher", `{"prompt":"find the answer"}`),
textTurn("final: incorporated child result"),
},
}
cfg := newFauxRunCfg(parent, sub)
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("delegate this")}},
}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
// The sub-agent tool must have executed exactly once in the parent loop.
if got := countKind(kinds, agentcore.EventToolExecutionStart); got != 1 {
t.Errorf("expected 1 sub-agent tool execution, got %d in %v", got, kinds)
}
// The child provider must have been driven independently.
if child.callCount() != 1 {
t.Errorf("child provider called %d times, want 1", child.callCount())
}
// The tool result fed back to the parent must carry the child's final text.
var toolResult *agentcore.ToolResultMessage
for i := range msgs {
if tr, ok := msgs[i].(agentcore.ToolResultMessage); ok {
toolResult = &tr
break
}
}
if toolResult == nil {
t.Fatalf("no tool result message in parent transcript: %+v", msgs)
}
if got := textContentOf(toolResult.Content); got != "child result: 42" {
t.Errorf("sub-agent result = %q, want %q (child final text fed to parent)", got, "child result: 42")
}
if toolResult.IsError {
t.Errorf("sub-agent tool result should not be an error")
}
// The parent's final message incorporates the delegation.
final := agentcore.LastAssistantOf(msgs)
if final == nil || textContentOf(final.Content) != "final: incorporated child result" {
t.Errorf("parent final text = %+v, want the post-delegation answer", final)
}
}
// TestSubAgentConcurrent verifies multiple sub-agents can run concurrently:
// the parent issues two parallel sub-agent tool calls in one turn, each spawning
// an independent child loop, and both results are fed back.
func TestSubAgentConcurrent(t *testing.T) {
mkChild := func(answer string) *SubAgentTool {
cp := &fauxProvider{
name: "faux-child-" + answer,
models: []provider.Model{{Provider: "c", ID: "c"}},
turns: []fauxTurn{textTurn(answer)},
}
return NewSubAgentTool(SubAgentSpec{
Name: "agent-" + answer,
Description: "child " + answer,
NewRunConfig: func() RunConfig {
return RunConfig{
LoopConfig: LoopConfig{Model: "c", Stream: provider.StreamFnFromProvider(cp)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}},
}
},
})
}
a, b := mkChild("alpha"), mkChild("beta")
// A single parent turn emitting two tool calls → both run in the same batch.
twoCall := fauxTurn{
provider.StreamStartEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}},
provider.StreamToolCallEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{
agentcore.NewToolCallContent("c1", "agent-alpha", json.RawMessage(`{"prompt":"go"}`)),
agentcore.NewToolCallContent("c2", "agent-beta", json.RawMessage(`{"prompt":"go"}`)),
}}},
provider.StreamDoneEvent{Message: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonToolUse, Content: agentcore.ContentList{
agentcore.NewToolCallContent("c1", "agent-alpha", json.RawMessage(`{"prompt":"go"}`)),
agentcore.NewToolCallContent("c2", "agent-beta", json.RawMessage(`{"prompt":"go"}`)),
}}},
}
parent := &fauxProvider{
name: "faux-parent",
models: []provider.Model{{Provider: "p", ID: "p"}},
turns: []fauxTurn{twoCall, textTurn("done")},
}
cfg := newFauxRunCfg(parent, a, b)
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("delegate both")}},
}}
_, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
got := map[string]string{}
for _, m := range msgs {
if tr, ok := m.(agentcore.ToolResultMessage); ok {
got[tr.ToolCallID] = textContentOf(tr.Content)
}
}
if got["c1"] != "alpha" || got["c2"] != "beta" {
t.Errorf("concurrent sub-agent results = %v, want c1=alpha c2=beta", got)
}
}
// TestSubAgentEmptyPromptErrors verifies a sub-agent invoked with no prompt
// fails cleanly rather than spawning an empty child.
func TestSubAgentEmptyPromptErrors(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{
Name: "x",
Description: "x",
NewRunConfig: func() RunConfig { return RunConfig{} },
})
if _, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":""}`), nil); err == nil {
t.Error("empty prompt must error")
}
}
// TestSubAgentFailedChildErrors verifies a child whose final turn stopped on
// error/aborted is surfaced to the parent as a tool error (not a silent
// success), so the parent model learns the delegation failed.
func TestSubAgentFailedChildErrors(t *testing.T) {
// A child turn that ends with StopReason=error carrying diagnostic text.
errTurn := func(text string) fauxTurn {
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
withText := partial
withText.Content = agentcore.ContentList{agentcore.NewTextContent(text)}
final := withText
final.StopReason = agentcore.StopReasonError
return fauxTurn{
provider.StreamStartEvent{Partial: partial},
provider.StreamTextEvent{Partial: withText},
provider.StreamDoneEvent{Message: final},
}
}
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{errTurn("provider blew up")},
}
sub := NewSubAgentTool(SubAgentSpec{
Name: "researcher",
Description: "delegate",
NewRunConfig: func() RunConfig {
return RunConfig{
LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}},
}
},
})
_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
if err == nil {
t.Fatal("a child that stopped on error must surface as a tool error")
}
if !strings.Contains(err.Error(), "provider blew up") {
t.Errorf("error should carry the child's diagnostic text, got %v", err)
}
}
// --- Skills -----------------------------------------------------------------
// TestParseSkill verifies frontmatter + body parsing, including the name/body
// split and the required-description guard.
func TestParseSkill(t *testing.T) {
content := []byte("---\nname: summarize\ndescription: summarize a file\nallowed-tools:\n - read\n---\nYou summarize files.\nBe concise.\n")
sk, err := ParseSkill("summarize.md", content)
if err != nil {
t.Fatalf("ParseSkill: %v", err)
}
if sk.Frontmatter.Name != "summarize" {
t.Errorf("name = %q, want summarize", sk.Frontmatter.Name)
}
if sk.Frontmatter.Description != "summarize a file" {
t.Errorf("description = %q", sk.Frontmatter.Description)
}
if len(sk.Frontmatter.AllowedTools) != 1 || sk.Frontmatter.AllowedTools[0] != "read" {
t.Errorf("allowed-tools = %v, want [read]", sk.Frontmatter.AllowedTools)
}
if !strings.Contains(sk.Body, "You summarize files.") {
t.Errorf("body missing instructions: %q", sk.Body)
}
}
// TestParseSkillDefaultsNameToFile verifies a skill without an explicit name
// defaults to its file base name.
func TestParseSkillDefaultsNameToFile(t *testing.T) {
sk, err := ParseSkill("/skills/deploy.md", []byte("---\ndescription: deploys\n---\nbody"))
if err != nil {
t.Fatalf("ParseSkill: %v", err)
}
if sk.Frontmatter.Name != "deploy" {
t.Errorf("name defaulted to %q, want deploy", sk.Frontmatter.Name)
}
}
// TestParseSkillRequiresDescription verifies the description guard.
func TestParseSkillRequiresDescription(t *testing.T) {
if _, err := ParseSkill("x.md", []byte("---\nname: x\n---\nbody")); err == nil {
t.Error("skill without description must error")
}
if _, err := ParseSkill("x.md", []byte("no frontmatter here")); err == nil {
t.Error("skill without frontmatter must error")
}
}
// TestParseSkillDisableModelInvocation verifies the disable-model-invocation
// frontmatter key parses into the three expected states.
func TestParseSkillDisableModelInvocation(t *testing.T) {
cases := []struct {
name string
yaml string
want bool
}{
{"absent defaults false", "---\nname: a\ndescription: d\n---\nbody", false},
{"explicit true", "---\nname: a\ndescription: d\ndisable-model-invocation: true\n---\nbody", true},
{"explicit false", "---\nname: a\ndescription: d\ndisable-model-invocation: false\n---\nbody", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sk, err := ParseSkill("a.md", []byte(tc.yaml))
if err != nil {
t.Fatalf("ParseSkill: %v", err)
}
if sk.Frontmatter.DisableModelInvocation != tc.want {
t.Errorf("DisableModelInvocation = %v, want %v", sk.Frontmatter.DisableModelInvocation, tc.want)
}
})
}
}
// TestValidateSkillName verifies the Agent Skills name rules: lowercase
// a-z/0-9/hyphen only, at most 64 chars, no leading/trailing/consecutive
// hyphens.
func TestValidateSkillName(t *testing.T) {
valid := []string{"weather", "chao-go-sync", "a", "a1-b2"}
for _, n := range valid {
if err := validateSkillName(n); err != nil {
t.Errorf("validateSkillName(%q) = %v, want nil", n, err)
}
}
invalid := []string{
"Weather", // uppercase
"my_skill", // underscore
"has space", // space
"plugin:skill", // colon
"-lead", // leading hyphen
"trail-", // trailing hyphen
"double--hyphen", // consecutive hyphens
strings.Repeat("a", 65), // too long
}
for _, n := range invalid {
if err := validateSkillName(n); err == nil {
t.Errorf("validateSkillName(%q) = nil, want error", n)
}
}
}
// TestValidateSkillDescription verifies description is required and bounded.
func TestValidateSkillDescription(t *testing.T) {
if err := validateSkillDescription("does a thing"); err != nil {
t.Errorf("valid description rejected: %v", err)
}
if err := validateSkillDescription(" "); err == nil {
t.Error("blank description must error")
}
if err := validateSkillDescription(strings.Repeat("x", 1025)); err == nil {
t.Error("over-long description must error")
}
if err := validateSkillDescription(strings.Repeat("x", 1024)); err != nil {
t.Errorf("1024-char description rejected: %v", err)
}
}
// TestParseSkillRejectsInvalidName verifies an invalid name (including one
// derived from the file base name) makes ParseSkill fail, so LoadSkillsDir
// skips it and accumulates the reason rather than surfacing a bad skill.
func TestParseSkillRejectsInvalidName(t *testing.T) {
if _, err := ParseSkill("x.md", []byte("---\nname: Bad_Name\ndescription: d\n---\nbody")); err == nil {
t.Error("invalid explicit name must error")
}
if _, err := ParseSkill("/skills/My_Skill.md", []byte("---\ndescription: d\n---\nbody")); err == nil {
t.Error("invalid file-derived name must error")
}
}
// TestFormatSkillsForPrompt verifies the <available_skills> block lists visible
// skills with name/description/location and excludes disabled ones.
func TestFormatSkillsForPrompt(t *testing.T) {
skills := []*Skill{
{Frontmatter: SkillFrontmatter{Name: "weather", Description: "get weather"}, Path: "/skills/weather.md"},
{Frontmatter: SkillFrontmatter{Name: "secret", Description: "hidden", DisableModelInvocation: true}, Path: "/skills/secret.md"},
}
out := FormatSkillsForPrompt(skills)
if !strings.Contains(out, "<available_skills>") || !strings.Contains(out, "</available_skills>") {
t.Fatalf("missing block wrapper:\n%s", out)
}
if !strings.Contains(out, "<name>weather</name>") {
t.Errorf("visible skill name missing:\n%s", out)
}
if !strings.Contains(out, "<description>get weather</description>") {
t.Errorf("visible skill description missing:\n%s", out)
}
if !strings.Contains(out, "<location>/skills/weather.md</location>") {
t.Errorf("visible skill location missing:\n%s", out)
}
if strings.Contains(out, "secret") {
t.Errorf("disabled skill must be excluded:\n%s", out)
}
if !strings.Contains(out, "Use the read tool to load a skill's file") {
t.Errorf("guidance preamble missing:\n%s", out)
}
}
// TestFormatSkillsForPromptEmpty verifies an empty or all-disabled list yields
// the empty string so callers can append unconditionally.
func TestFormatSkillsForPromptEmpty(t *testing.T) {
if got := FormatSkillsForPrompt(nil); got != "" {
t.Errorf("nil skills = %q, want empty", got)
}
disabled := []*Skill{{Frontmatter: SkillFrontmatter{Name: "x", Description: "d", DisableModelInvocation: true}, Path: "x.md"}}
if got := FormatSkillsForPrompt(disabled); got != "" {
t.Errorf("all-disabled skills = %q, want empty", got)
}
}
// TestFormatSkillsForPromptEscapesXML verifies XML special characters in name
// and description are escaped.
func TestFormatSkillsForPromptEscapesXML(t *testing.T) {
skills := []*Skill{
{Frontmatter: SkillFrontmatter{Name: "a", Description: `x & y < z > "q" 'r'`}, Path: "/s/a.md"},
}
out := FormatSkillsForPrompt(skills)
if !strings.Contains(out, "x &amp; y &lt; z &gt; &quot;q&quot; &apos;r&apos;") {
t.Errorf("XML not escaped:\n%s", out)
}
if strings.Contains(out, "& y") || strings.Contains(out, "< z") {
t.Errorf("raw XML chars leaked:\n%s", out)
}
}
// TestFormatSkillsForPromptAbsoluteLocation verifies a relative skill path is
// rendered as an absolute location so the model can read it from any cwd.
func TestFormatSkillsForPromptAbsoluteLocation(t *testing.T) {
skills := []*Skill{
{Frontmatter: SkillFrontmatter{Name: "rel", Description: "d"}, Path: "sub/rel.md"},
}
out := FormatSkillsForPrompt(skills)
if !strings.Contains(out, "<location>"+string(filepath.Separator)) && !strings.Contains(out, "<location>/") {
t.Errorf("location should be absolute:\n%s", out)
}
}
// TestLoadSkillsDir verifies loading flat *.md skills and nested <name>/SKILL.md,
// sorted by name; a missing dir is not an error.
func TestLoadSkillsDir(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "beta.md"), []byte("---\ndescription: beta skill\n---\nbeta body"), 0o644); err != nil {
t.Fatal(err)
}
nested := filepath.Join(dir, "alpha")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("---\nname: alpha\ndescription: alpha skill\n---\nalpha body"), 0o644); err != nil {
t.Fatal(err)
}
skills, err := LoadSkillsDir(dir)
if err != nil {
t.Fatalf("LoadSkillsDir: %v", err)
}
if len(skills) != 2 {
t.Fatalf("loaded %d skills, want 2", len(skills))
}
if skills[0].Frontmatter.Name != "alpha" || skills[1].Frontmatter.Name != "beta" {
t.Errorf("skills not sorted by name: %q, %q", skills[0].Frontmatter.Name, skills[1].Frontmatter.Name)
}
// Missing directory → no skills, no error.
empty, err := LoadSkillsDir(filepath.Join(dir, "does-not-exist"))
if err != nil || empty != nil {
t.Errorf("missing dir should yield (nil, nil), got (%v, %v)", empty, err)
}
}
// TestSkillSubAgentSpec verifies a skill materializes as a sub-agent whose
// system prompt is the body, whose tools are filtered by allowed-tools, and
// whose description is surfaced.
func TestSkillSubAgentSpec(t *testing.T) {
sk := &Skill{
Frontmatter: SkillFrontmatter{Name: "reader", Description: "reads", AllowedTools: []string{"read"}},
Body: "you read files",
}
tools := []agentcore.AgentTool{execTool{name: "read"}, execTool{name: "write"}, execTool{name: "bash"}}
var gotTools []agentcore.AgentTool
spec := sk.SubAgentSpec(tools, func(resolved []agentcore.AgentTool) RunConfig {
gotTools = resolved
return RunConfig{}
})
if spec.Name != "reader" || spec.Description != "reads" {
t.Errorf("spec identity = %q/%q", spec.Name, spec.Description)
}
if spec.SystemPrompt != "you read files" {
t.Errorf("spec system prompt = %q", spec.SystemPrompt)
}
if len(spec.Tools) != 1 || spec.Tools[0].Name() != "read" {
t.Errorf("allowed-tools filter failed, spec tools = %v", spec.Tools)
}
// NewRunConfig passes the resolved (filtered) tool set to the factory.
spec.NewRunConfig()
if len(gotTools) != 1 || gotTools[0].Name() != "read" {
t.Errorf("factory received %v, want [read]", gotTools)
}
}
// --- Slash-commands ---------------------------------------------------------
// TestSlashBuiltinWinsOverUser is acceptance-critical: the conflict priority
// rule keeps a built-in over a same-named user command, recording the shadow.
func TestSlashBuiltinWinsOverUser(t *testing.T) {
// Register a built-in under a unique name to avoid cross-test pollution.
name := "compact-test-builtin"
if _, exists := builtinCommands[name]; !exists {
RegisterBuiltin(SlashCommand{Name: name, Description: "builtin", Expand: func(string) string { return "BUILTIN" }})
}
r := NewSlashRegistry()
r.AddUser(SlashCommand{Name: name, Expand: func(string) string { return "USER" }})
cmd, ok := r.Lookup(name)
if !ok {
t.Fatalf("command %q not found", name)
}
if cmd.Source != SourceBuiltin {
t.Errorf("built-in must win, got source %v", cmd.Source)
}
if got := cmd.Expand(""); got != "BUILTIN" {
t.Errorf("expanded %q, want BUILTIN (built-in wins)", got)
}
shadowed := r.Shadowed()
if len(shadowed) != 1 || shadowed[0].Name != name {
t.Errorf("shadowed = %v, want [%s]", shadowed, name)
}
}
// TestSlashResolve verifies "/name args" parsing, non-command passthrough, and
// the unknown-command error.
func TestSlashResolve(t *testing.T) {
r := NewSlashRegistry()
r.AddUser(SlashCommand{Name: "greet", Expand: func(args string) string { return "hello " + args }})
// Slash command with args → expanded.
prompt, handled, err := r.Resolve("/greet world")
if err != nil || !handled || prompt != "hello world" {
t.Errorf("Resolve(/greet world) = (%q, %v, %v)", prompt, handled, err)
}
// Non-command passthrough.
prompt, handled, err = r.Resolve("just a normal prompt")
if err != nil || handled || prompt != "just a normal prompt" {
t.Errorf("non-command passthrough failed: (%q, %v, %v)", prompt, handled, err)
}
// Unknown command errors.
if _, _, err := r.Resolve("/nope"); err == nil {
t.Error("unknown command must error")
}
}
// TestSlashActionCommand verifies an action command runs its side effect via
// ResolveOutcome and reports SlashAction with its status message (no prompt),
// while a prompt command reports SlashPrompt with expanded text.
func TestSlashActionCommand(t *testing.T) {
r := NewSlashRegistry()
var ran string
r.AddBuiltin(SlashCommand{
Name: "model",
Description: "switch model",
Action: func(args string) string { ran = args; return "switched to " + args },
})
r.AddUser(SlashCommand{Name: "greet", Expand: func(args string) string { return "hello " + args }})
// Action command: runs the side effect and returns a status message.
out, err := r.ResolveOutcome("/model gpt-5")
if err != nil {
t.Fatalf("ResolveOutcome(/model) error: %v", err)
}
if !out.Handled || out.Kind != SlashAction {
t.Errorf("action command: got handled=%v kind=%v, want true/SlashAction", out.Handled, out.Kind)
}
if ran != "gpt-5" {
t.Errorf("action side effect not run with args: got %q", ran)
}
if out.Message != "switched to gpt-5" || out.Prompt != "" {
t.Errorf("action outcome = {msg:%q prompt:%q}, want status message and empty prompt", out.Message, out.Prompt)
}
// Prompt command: expands, no action.
out, err = r.ResolveOutcome("/greet world")
if err != nil {
t.Fatalf("ResolveOutcome(/greet) error: %v", err)
}
if !out.Handled || out.Kind != SlashPrompt || out.Prompt != "hello world" {
t.Errorf("prompt outcome = {kind:%v prompt:%q}, want SlashPrompt/hello world", out.Kind, out.Prompt)
}
// Non-command passthrough.
out, err = r.ResolveOutcome("plain text")
if err != nil || out.Handled || out.Prompt != "plain text" {
t.Errorf("passthrough = {%v %q %v}, want unhandled verbatim", out.Handled, out.Prompt, err)
}
}
// TestAddBuiltinDuplicatePanics verifies AddBuiltin rejects a duplicate
// built-in name (a programming error), matching RegisterBuiltin semantics.
func TestAddBuiltinDuplicatePanics(t *testing.T) {
r := NewSlashRegistry()
r.AddBuiltin(SlashCommand{Name: "dup", Action: func(string) string { return "" }})
defer func() {
if recover() == nil {
t.Error("duplicate AddBuiltin must panic")
}
}()
r.AddBuiltin(SlashCommand{Name: "dup", Action: func(string) string { return "" }})
}
// TestAddBuiltinWinsOverUser verifies an instance built-in (AddBuiltin) shadows
// a same-named user command, just like a globally registered built-in.
func TestAddBuiltinWinsOverUser(t *testing.T) {
r := NewSlashRegistry()
r.AddBuiltin(SlashCommand{Name: "x", Action: func(string) string { return "builtin" }})
r.AddUser(SlashCommand{Name: "x", Expand: func(string) string { return "user" }})
cmd, ok := r.Lookup("x")
if !ok || cmd.Source != SourceBuiltin {
t.Errorf("instance built-in must win, got ok=%v source=%v", ok, cmd.Source)
}
if len(r.Shadowed()) != 1 || r.Shadowed()[0].Name != "x" {
t.Errorf("shadowed = %v, want [x]", r.Shadowed())
}
}
// TestParseUserCommand verifies $ARGUMENTS substitution, frontmatter description,
// and the append fallback when no placeholder is present.
func TestParseUserCommand(t *testing.T) {
// With frontmatter + placeholder.
cmd, err := ParseUserCommand("review", []byte("---\ndescription: review code\n---\nReview this: $ARGUMENTS please"))
if err != nil {
t.Fatalf("ParseUserCommand: %v", err)
}
if cmd.Description != "review code" {
t.Errorf("description = %q", cmd.Description)
}
if got := cmd.Expand("main.go"); got != "Review this: main.go please" {
t.Errorf("expand = %q", got)
}
// No placeholder: args appended.
bare, _ := ParseUserCommand("note", []byte("Take a note"))
if got := bare.Expand("buy milk"); got != "Take a note\n\nbuy milk" {
t.Errorf("bare expand = %q", got)
}
if got := bare.Expand(""); got != "Take a note" {
t.Errorf("bare expand no args = %q", got)
}
}
// TestLoadUserCommandsDir verifies loading *.md command templates from a dir,
// sorted by name; a missing dir is not an error.
func TestLoadUserCommandsDir(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "deploy.md"), []byte("Deploy $ARGUMENTS now"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "test.md"), []byte("---\ndescription: run tests\n---\nRun tests"), 0o644); err != nil {
t.Fatal(err)
}
cmds, err := LoadUserCommandsDir(dir)
if err != nil {
t.Fatalf("LoadUserCommandsDir: %v", err)
}
if len(cmds) != 2 || cmds[0].Name != "deploy" || cmds[1].Name != "test" {
t.Fatalf("loaded %d commands (want deploy,test sorted): %+v", len(cmds), cmds)
}
if got := cmds[0].Expand("prod"); got != "Deploy prod now" {
t.Errorf("deploy expand = %q", got)
}
empty, err := LoadUserCommandsDir(filepath.Join(dir, "missing"))
if err != nil || empty != nil {
t.Errorf("missing dir should yield (nil, nil), got (%v, %v)", empty, err)
}
}
+149
View File
@@ -0,0 +1,149 @@
package runtime
// Tests for sub-agent progress reporting (US-005, #455): a dispatched task's
// child tool-execution / turn boundaries are translated into
// SubAgentProgressEvent and surfaced through the run-level emitter the parent
// loop injects into ctx (WithProgressEmitter). The parent tool-call id must ride
// on the event, the activity must map from the child event, and a nil emitter
// (the tool called outside a loop, e.g. a direct unit test) must be a silent
// no-op rather than a panic. The child loop is driven through the faux provider
// seam; only the provider boundary is faked.
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/provider"
)
// TestActivityOf pins the child-event → activity mapping (D-8 / §5.3): tool
// starts map to their display verb, a turn start maps to "Thinking", and
// everything else maps to "" (no emission).
func TestActivityOf(t *testing.T) {
cases := []struct {
ev agentcore.AgentEvent
want string
}{
{agentcore.ToolExecutionStartEvent{ToolName: "read"}, "Reading"},
{agentcore.ToolExecutionStartEvent{ToolName: "edit"}, "Editing"},
{agentcore.ToolExecutionStartEvent{ToolName: "write"}, "Editing"},
{agentcore.ToolExecutionStartEvent{ToolName: "bash"}, "Running bash"},
{agentcore.ToolExecutionStartEvent{ToolName: "grep"}, "Searching"},
{agentcore.ToolExecutionStartEvent{ToolName: "find"}, "Searching"},
{agentcore.ToolExecutionStartEvent{ToolName: "ls"}, "Searching"},
{agentcore.ToolExecutionStartEvent{ToolName: "webfetch"}, "Fetching"},
{agentcore.ToolExecutionStartEvent{ToolName: "todo"}, ""},
{agentcore.TurnStartEvent{}, "Thinking"},
{agentcore.ToolExecutionEndEvent{ToolName: "read"}, ""},
{agentcore.MessageStartEvent{}, ""},
}
for _, c := range cases {
if got := activityOf(c.ev); got != c.want {
t.Errorf("activityOf(%T{%v}) = %q, want %q", c.ev, c.ev, got, c.want)
}
}
}
// TestTaskEmitsSubAgentProgress verifies a child that executes a tool triggers a
// SubAgentProgressEvent carrying the parent task's tool-call id and the mapped
// activity ("Reading" for a child "read" tool), plus the "Thinking" boundary at
// each turn start.
func TestTaskEmitsSubAgentProgress(t *testing.T) {
// A child tool named "read" so its ToolExecutionStart maps to "Reading".
readTool := execTool{
name: "read",
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("file body")}}, nil
},
}
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{toolCallTurn("t1", "read", `{}`), textTurn("child final report")},
}
factory := func() RunConfig {
reg := agenttool.NewToolRegistry()
_ = reg.Register(readTool)
return RunConfig{
LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}},
}
}
tool := NewTaskTool(factory, nil)
var mu sync.Mutex
var progress []agentcore.SubAgentProgressEvent
emit := func(ctx context.Context, ev agentcore.AgentEvent) error {
if p, ok := ev.(agentcore.SubAgentProgressEvent); ok {
mu.Lock()
progress = append(progress, p)
mu.Unlock()
}
return nil
}
ctx := agentcore.WithProgressEmitter(context.Background(), emit)
const parentID = "parent-call-id"
res, err := tool.Execute(ctx, parentID, json.RawMessage(`{"description":"read the file","prompt":"do the work"}`), nil)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if got := agentcore.ContentToText(res.Content); got != "child final report" {
t.Errorf("task result = %q, want 'child final report'", got)
}
mu.Lock()
defer mu.Unlock()
if len(progress) == 0 {
t.Fatal("expected at least one SubAgentProgressEvent, got none")
}
var sawReading bool
for _, p := range progress {
if p.ToolCallID != parentID {
t.Errorf("progress ToolCallID = %q, want %q", p.ToolCallID, parentID)
}
if p.Description != "read the file" {
t.Errorf("progress Description = %q, want 'read the file'", p.Description)
}
if p.Activity == "" {
t.Errorf("progress emitted with empty Activity (should be skipped)")
}
if p.Activity == "Reading" {
sawReading = true
}
}
if !sawReading {
t.Errorf("expected a 'Reading' activity from the child read tool, got %+v", progress)
}
}
// TestTaskNilEmitterNoPanic verifies that when no progress emitter is present in
// ctx (the tool called outside a loop, as in direct unit tests) the child still
// runs, returns its text, and emits no progress — without panicking.
func TestTaskNilEmitterNoPanic(t *testing.T) {
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{textTurn("child final report")},
}
factory := func() RunConfig {
return RunConfig{
LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}},
}
}
tool := NewTaskTool(factory, nil)
// Plain ctx: no WithProgressEmitter, so ProgressEmitterFromContext is nil.
res, err := tool.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if got := agentcore.ContentToText(res.Content); got != "child final report" {
t.Errorf("task result = %q, want 'child final report'", got)
}
}
+203
View File
@@ -0,0 +1,203 @@
// This file implements system-prompt assembly (US-021, #40), the pigo port of
// pi's prompt construction. A run's system prompt is built from three layers,
// in order:
//
// 1. a base instruction (who the agent is and how it should behave),
// 2. an environment block (working directory, OS/arch, current date), and
// 3. every AGENTS.md found on the path from a root directory down to the
// working directory, concatenated general-to-specific.
//
// The AGENTS.md ordering mirrors zero/pi's monorepo behavior: a repo-root
// AGENTS.md states broad conventions, and a nested package's AGENTS.md refines
// them, so the more specific file appears later and takes precedence in the
// model's reading.
package runtime
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
// agentsFileName is the per-directory instruction file injected into the system
// prompt, general-to-specific from the root down to the working directory.
const agentsFileName = "AGENTS.md"
// PromptConfig configures system-prompt assembly. The zero value is usable: it
// produces the base instruction plus an environment block for the process
// working directory, with no AGENTS.md injection.
type PromptConfig struct {
// BaseInstruction is the leading text of the system prompt. When empty,
// DefaultBaseInstruction is used.
BaseInstruction string
// WorkingDir is the directory the run operates in. When empty, the process
// working directory (os.Getwd) is used. It anchors both the environment
// block and the lower bound of the AGENTS.md walk.
WorkingDir string
// Root bounds the AGENTS.md walk at its top. AGENTS.md files are injected for
// every directory from Root down to WorkingDir, inclusive. When empty, only
// WorkingDir's own AGENTS.md (if any) is considered — no ancestor walk.
Root string
// AppendInstructions are appended verbatim to the end of the assembled
// prompt, in order, each preceded by a blank line. This is the sink for
// --append-system-prompt (mirrors pi): extra guidance layered after the base
// instruction, environment block, and AGENTS.md. Empty entries are skipped.
AppendInstructions []string
// Now supplies the timestamp for the environment block. When nil, time.Now
// is used. Injected for deterministic tests.
Now func() time.Time
// ReadFile reads a file's contents. When nil, os.ReadFile is used. Injected
// for tests so AGENTS.md layout can be faked without touching disk.
ReadFile func(path string) ([]byte, error)
// Skills are the model-invocable skills to advertise in an <available_skills>
// block at the end of the prompt (mirrors pi's progressive disclosure). Only their
// name/description/location are injected; the model loads a skill's body with
// the read tool on demand. Skills flagged disable-model-invocation are
// filtered out by FormatSkillsForPrompt. Empty means no skills block.
Skills []*Skill
// ReadToolAvailable signals whether the read tool is in the current tool set.
// Skills are advertised only when it is true, since the model needs the read
// tool to load a skill's body; otherwise the block is omitted entirely.
ReadToolAvailable bool
}
// DefaultBaseInstruction is the leading system-prompt text used when
// PromptConfig.BaseInstruction is empty.
const DefaultBaseInstruction = "You are pigo, a helpful coding agent. " +
"Use the available tools to inspect files and accomplish the user's request precisely and concisely.\n\n" +
todoGuide + "\n\n" +
taskGuide
// todoGuide instructs the model on how to drive the todo tool. It is appended to
// the default base instruction so multi-step work is planned and its progress is
// made visible to the user (US-011).
const todoGuide = "When a task has multiple steps or is non-trivial, use the todo tool to plan " +
"and track your work. Submit the entire task list each call (it replaces the previous " +
"list); each item has a content string and a status of pending, in_progress, or completed. " +
"Keep exactly one item in_progress at a time, and mark an item completed as soon as it is " +
"done before starting the next. Skip the todo tool for trivial single-step requests."
// taskGuide instructs the model on how to use the generic `task` tool (US-008,
// #458). The task tool dispatches an independent sub-agent that runs its own
// agent loop with a fresh context and returns its final report, so it is the
// mechanism for delegation and fan-out. The key affordance advertised here is
// that emitting MULTIPLE task calls in a single assistant message runs those
// sub-agents in parallel, letting skills like /graph achieve real concurrency.
const taskGuide = "When work splits into independent subtasks, delegate them with the task tool: each " +
"task call dispatches an independent sub-agent that completes its subtask on a fresh context and " +
"returns its final report. To fan out, emit MULTIPLE task calls in a single message — they run in " +
"parallel. Give each a complete, self-contained prompt, since a sub-agent shares none of this " +
"conversation's context. Do the work directly for a single, sequential, or trivial task."
// BuildSystemPrompt assembles the full system prompt from cfg: base instruction,
// environment block, then AGENTS.md files ordered general-to-specific from Root
// down to WorkingDir. Missing AGENTS.md files are skipped silently; only a
// present-but-unreadable file (a real I/O error other than not-exist) is
// reported.
func BuildSystemPrompt(cfg PromptConfig) (string, error) {
base := cfg.BaseInstruction
if base == "" {
base = DefaultBaseInstruction
}
wd := cfg.WorkingDir
if wd == "" {
if cwd, err := os.Getwd(); err == nil {
wd = cwd
}
}
now := cfg.Now
if now == nil {
now = time.Now
}
readFile := cfg.ReadFile
if readFile == nil {
readFile = os.ReadFile
}
var b strings.Builder
b.WriteString(base)
b.WriteString("\n\nEnvironment:\n")
fmt.Fprintf(&b, "- Working directory: %s\n", wd)
fmt.Fprintf(&b, "- OS: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Fprintf(&b, "- Date: %s", now().Format("2006-01-02"))
dirs := agentsDirChain(cfg.Root, wd)
for _, dir := range dirs {
path := filepath.Join(dir, agentsFileName)
data, err := readFile(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return "", fmt.Errorf("read %s: %w", path, err)
}
content := strings.TrimSpace(string(data))
if content == "" {
continue
}
fmt.Fprintf(&b, "\n\n# Project instructions (%s)\n%s", path, content)
}
// Appended instructions (--append-system-prompt) come last so they layer on
// top of the base instruction, environment, and AGENTS.md. Each is separated
// by a blank line; empty entries are skipped.
for _, extra := range cfg.AppendInstructions {
extra = strings.TrimSpace(extra)
if extra == "" {
continue
}
b.WriteString("\n\n")
b.WriteString(extra)
}
// Advertise model-invocable skills last (progressive disclosure), but only
// when the read tool is available — the model needs it to load a skill's
// body. FormatSkillsForPrompt returns "" when no visible skill remains, so
// this leaves a skill-free prompt byte-for-byte unchanged.
if cfg.ReadToolAvailable {
b.WriteString(FormatSkillsForPrompt(cfg.Skills))
}
return b.String(), nil
}
// agentsDirChain returns the directories whose AGENTS.md should be injected, in
// general-to-specific order (root first, working directory last). When root is
// empty or is not an ancestor of wd, only wd is returned. When wd is empty, the
// chain is empty.
func agentsDirChain(root, wd string) []string {
if wd == "" {
return nil
}
wd = filepath.Clean(wd)
if root == "" {
return []string{wd}
}
root = filepath.Clean(root)
// Walk up from wd to root, collecting each directory, then reverse so the
// root comes first (general → specific). If root is never reached, wd is not
// under root, so fall back to wd alone.
var up []string
cur := wd
for {
up = append(up, cur)
if cur == root {
// Reverse in place: root-first.
for i, j := 0, len(up)-1; i < j; i, j = i+1, j-1 {
up[i], up[j] = up[j], up[i]
}
return up
}
parent := filepath.Dir(cur)
if parent == cur {
// Reached filesystem root without hitting `root`: wd not under root.
return []string{wd}
}
cur = parent
}
}
+315
View File
@@ -0,0 +1,315 @@
package runtime
// Tests for system-prompt assembly (US-021, #40): the base instruction, the
// environment block, and — the acceptance-critical part — the general-to-
// specific ordering of AGENTS.md injection from a root directory down to the
// working directory. AGENTS.md layout is faked via PromptConfig.ReadFile so the
// ordering is asserted without touching disk.
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fixedTime is a deterministic clock for the environment block.
func fixedTime() time.Time { return time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) }
// TestBuildSystemPromptBaseAndEnv verifies the base instruction and environment
// block (cwd, OS, date) are present, with no AGENTS.md when none exist.
func TestBuildSystemPromptBaseAndEnv(t *testing.T) {
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: "/work/proj",
Now: fixedTime,
ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist },
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
if !strings.HasPrefix(got, DefaultBaseInstruction) {
t.Errorf("prompt should start with the default base instruction, got:\n%s", got)
}
if !strings.Contains(got, "Working directory: /work/proj") {
t.Errorf("environment block missing working directory:\n%s", got)
}
if !strings.Contains(got, "Date: 2026-07-10") {
t.Errorf("environment block missing date:\n%s", got)
}
if strings.Contains(got, "Project instructions") {
t.Errorf("no AGENTS.md exists, but prompt injected one:\n%s", got)
}
}
// TestBuildSystemPromptAdvertisesTaskFanout verifies the base instruction tells
// the model about the generic task tool: that it dispatches an independent
// sub-agent (delegation) and that multiple task calls in one message run in
// parallel (fan-out, US-008/#458).
func TestBuildSystemPromptAdvertisesTaskFanout(t *testing.T) {
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: "/work/proj",
Now: fixedTime,
ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist },
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
lower := strings.ToLower(got)
if !strings.Contains(lower, "task tool") {
t.Errorf("prompt should advertise the task tool:\n%s", got)
}
if !strings.Contains(lower, "sub-agent") || !strings.Contains(lower, "independent") {
t.Errorf("prompt should describe the task tool as an independent sub-agent (delegation):\n%s", got)
}
if !strings.Contains(lower, "parallel") {
t.Errorf("prompt should state that multiple task calls run in parallel (fan-out):\n%s", got)
}
}
// TestBuildSystemPromptAGENTSOrdering is the acceptance-critical test: with an
// AGENTS.md at the root and at a nested working directory, the root's content
// must appear BEFORE the nested one (general → specific).
func TestBuildSystemPromptAGENTSOrdering(t *testing.T) {
root := filepath.Clean("/repo")
mid := filepath.Join(root, "services")
wd := filepath.Join(mid, "api")
files := map[string]string{
filepath.Join(root, agentsFileName): "ROOT CONVENTIONS",
filepath.Join(mid, agentsFileName): "SERVICES CONVENTIONS",
filepath.Join(wd, agentsFileName): "API CONVENTIONS",
}
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: wd,
Root: root,
Now: fixedTime,
ReadFile: func(path string) ([]byte, error) {
if c, ok := files[path]; ok {
return []byte(c), nil
}
return nil, os.ErrNotExist
},
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
iRoot := strings.Index(got, "ROOT CONVENTIONS")
iMid := strings.Index(got, "SERVICES CONVENTIONS")
iAPI := strings.Index(got, "API CONVENTIONS")
if iRoot < 0 || iMid < 0 || iAPI < 0 {
t.Fatalf("all three AGENTS.md must be injected, got:\n%s", got)
}
if !(iRoot < iMid && iMid < iAPI) {
t.Errorf("AGENTS.md must be ordered general→specific (root<mid<api), got positions root=%d mid=%d api=%d", iRoot, iMid, iAPI)
}
}
// TestBuildSystemPromptSkipsMissingIntermediate verifies a missing intermediate
// AGENTS.md is skipped without breaking the ordering of the present ones.
func TestBuildSystemPromptSkipsMissingIntermediate(t *testing.T) {
root := filepath.Clean("/repo")
mid := filepath.Join(root, "services")
wd := filepath.Join(mid, "api")
files := map[string]string{
filepath.Join(root, agentsFileName): "ROOT ONLY",
filepath.Join(wd, agentsFileName): "API ONLY",
// no AGENTS.md at mid
}
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: wd, Root: root, Now: fixedTime,
ReadFile: func(path string) ([]byte, error) {
if c, ok := files[path]; ok {
return []byte(c), nil
}
return nil, os.ErrNotExist
},
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
iRoot := strings.Index(got, "ROOT ONLY")
iAPI := strings.Index(got, "API ONLY")
if iRoot < 0 || iAPI < 0 || iRoot >= iAPI {
t.Errorf("present AGENTS.md must stay ordered root<api, got root=%d api=%d in:\n%s", iRoot, iAPI, got)
}
}
// TestBuildSystemPromptNoRootOnlyWorkingDir verifies that with no Root, only the
// working directory's own AGENTS.md is considered (no ancestor walk).
func TestBuildSystemPromptNoRootOnlyWorkingDir(t *testing.T) {
wd := filepath.Clean("/repo/services/api")
ancestor := filepath.Join(filepath.Dir(wd), agentsFileName)
files := map[string]string{
filepath.Join(wd, agentsFileName): "WD ONLY",
ancestor: "ANCESTOR (should NOT appear)",
}
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: wd, Now: fixedTime,
ReadFile: func(path string) ([]byte, error) {
if c, ok := files[path]; ok {
return []byte(c), nil
}
return nil, os.ErrNotExist
},
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
if !strings.Contains(got, "WD ONLY") {
t.Errorf("working-dir AGENTS.md must be injected:\n%s", got)
}
if strings.Contains(got, "ANCESTOR") {
t.Errorf("with no Root, ancestor AGENTS.md must not be walked:\n%s", got)
}
}
// TestBuildSystemPromptReadErrorSurfaces verifies a present-but-unreadable
// AGENTS.md (a non-not-exist I/O error) is reported rather than silently
// dropped.
func TestBuildSystemPromptReadErrorSurfaces(t *testing.T) {
wd := filepath.Clean("/repo")
_, err := BuildSystemPrompt(PromptConfig{
WorkingDir: wd, Now: fixedTime,
ReadFile: func(string) ([]byte, error) { return nil, os.ErrPermission },
})
if err == nil {
t.Fatal("an unreadable AGENTS.md must surface an error, got nil")
}
}
// TestBuildSystemPromptBaseInstructionOverride verifies a non-empty
// BaseInstruction replaces the default coding-assistant prompt (mirrors pi's
// --system-prompt) while the environment block still follows it.
func TestBuildSystemPromptBaseInstructionOverride(t *testing.T) {
got, err := BuildSystemPrompt(PromptConfig{
BaseInstruction: "You are a haiku poet.",
WorkingDir: "/work/proj",
Now: fixedTime,
ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist },
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
if !strings.HasPrefix(got, "You are a haiku poet.") {
t.Errorf("custom base instruction should lead the prompt, got:\n%s", got)
}
if strings.Contains(got, DefaultBaseInstruction) {
t.Errorf("default base instruction must not appear when overridden:\n%s", got)
}
if !strings.Contains(got, "Working directory: /work/proj") {
t.Errorf("environment block must still follow the custom base:\n%s", got)
}
}
// TestBuildSystemPromptAppendInstructions verifies --append-system-prompt
// entries are layered onto the end of the prompt in order, after the base
// instruction and environment block, with empty entries skipped.
func TestBuildSystemPromptAppendInstructions(t *testing.T) {
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: "/work/proj",
Now: fixedTime,
AppendInstructions: []string{"FIRST APPEND", " ", "SECOND APPEND"},
ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist },
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
iEnv := strings.Index(got, "Working directory")
iFirst := strings.Index(got, "FIRST APPEND")
iSecond := strings.Index(got, "SECOND APPEND")
if iFirst < 0 || iSecond < 0 {
t.Fatalf("both appended instructions must be present, got:\n%s", got)
}
if !(iEnv < iFirst && iFirst < iSecond) {
t.Errorf("appends must follow the env block and keep order (env<first<second), got env=%d first=%d second=%d", iEnv, iFirst, iSecond)
}
}
// TestBuildSystemPromptInjectsSkills verifies the <available_skills> block is
// appended after the base/env/append layers when the read tool is available.
func TestBuildSystemPromptInjectsSkills(t *testing.T) {
skills := []*Skill{
{Frontmatter: SkillFrontmatter{Name: "weather", Description: "get weather"}, Path: "/skills/weather.md"},
{Frontmatter: SkillFrontmatter{Name: "secret", Description: "hidden", DisableModelInvocation: true}, Path: "/skills/secret.md"},
}
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: "/work/proj",
Now: fixedTime,
ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist },
Skills: skills,
ReadToolAvailable: true,
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "<name>weather</name>") {
t.Errorf("skills block must be injected, got:\n%s", got)
}
if strings.Contains(got, "secret") {
t.Errorf("disable-model-invocation skill must not appear, got:\n%s", got)
}
iEnv := strings.Index(got, "Working directory")
iSkills := strings.Index(got, "<available_skills>")
if !(iEnv < iSkills) {
t.Errorf("skills block must come after env block, env=%d skills=%d", iEnv, iSkills)
}
}
// TestBuildSystemPromptNoSkillsWithoutReadTool verifies skills are NOT injected
// when the read tool is unavailable, and that a skill-free prompt is unchanged.
func TestBuildSystemPromptNoSkillsWithoutReadTool(t *testing.T) {
skills := []*Skill{{Frontmatter: SkillFrontmatter{Name: "weather", Description: "d"}, Path: "/s/weather.md"}}
withTool, _ := BuildSystemPrompt(PromptConfig{WorkingDir: "/w", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, Skills: skills, ReadToolAvailable: false})
if strings.Contains(withTool, "available_skills") {
t.Errorf("no read tool → no skills block, got:\n%s", withTool)
}
// A prompt with no skills at all must equal one with read tool but empty list.
bare, _ := BuildSystemPrompt(PromptConfig{WorkingDir: "/w", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }})
withReadNoSkills, _ := BuildSystemPrompt(PromptConfig{WorkingDir: "/w", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, ReadToolAvailable: true})
if bare != withReadNoSkills {
t.Errorf("empty skill list must not alter the prompt even with read tool:\n%q\nvs\n%q", bare, withReadNoSkills)
}
}
// TestDisableModelInvocationCoexistence verifies the #305 coexistence contract:
// a skill with disable-model-invocation:true is STILL exposed as a /skill-name
// slash command (body expansion + $ARGUMENTS), while being EXCLUDED from the
// <available_skills> prompt injection. The two invocation paths are independent.
func TestDisableModelInvocationCoexistence(t *testing.T) {
disabled := &Skill{
Frontmatter: SkillFrontmatter{Name: "secret", Description: "hidden", DisableModelInvocation: true},
Path: "/skills/secret.md",
Body: "Do the secret thing with $ARGUMENTS.",
}
enabled := &Skill{
Frontmatter: SkillFrontmatter{Name: "weather", Description: "get weather"},
Path: "/skills/weather.md",
Body: "Report the weather.",
}
skills := []*Skill{disabled, enabled}
// 1. The disabled skill must be excluded from the model-facing prompt block,
// while the enabled one appears.
block := FormatSkillsForPrompt(skills)
if strings.Contains(block, "secret") {
t.Errorf("disable-model-invocation skill must not appear in <available_skills>, got:\n%s", block)
}
if !strings.Contains(block, "<name>weather</name>") {
t.Errorf("model-invocable skill must appear in <available_skills>, got:\n%s", block)
}
// 2. The disabled skill must still be invocable via its /skill-name command,
// with $ARGUMENTS substitution intact (behavior identical to an enabled one).
cmd := disabled.SlashCommand()
if cmd.Name != "secret" {
t.Errorf("disabled skill slash name = %q, want secret", cmd.Name)
}
if cmd.Expand == nil {
t.Fatal("disabled skill must expose a prompt command (Expand != nil)")
}
if got := cmd.Expand("now"); got != "Do the secret thing with now." {
t.Errorf("Expand(now) = %q, want $ARGUMENTS substituted", got)
}
}
+165
View File
@@ -0,0 +1,165 @@
// Context rebuild for the "infinite context" feature (#482). Where auto-
// compaction (loop.go) collapses history *lossily* on the fly, a rebuild
// reconstructs the working context deterministically from a persisted
// checkpoint: everything before the checkpoint watermark is replaced by the
// distilled checkpoint summary, and everything at/after the watermark is kept
// verbatim. This is what the /rebuild command (REPL + TUI) invokes, and what
// the run loop (#481) will call on resume to reload a collapsed prefix instead
// of replaying the whole transcript.
//
// When no checkpoint exists yet there is nothing to reload, so a rebuild falls
// back to the ordinary lossy compaction path (the same compaction.Compact flow
// runCompaction drives) so /rebuild still shrinks an overgrown context.
//
// This file is deliberately side-effect free with respect to the loop: it never
// mutates the caller's AgentContext. It returns the rebuilt MessageList (plus a
// RebuildResult describing what happened and an equivalent CompactionEvent) so
// the CLI handlers — and, later, #481 — decide when and how to apply it.
package runtime
import (
"context"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
)
// RebuildResult describes the outcome of a context rebuild. Messages is the
// rebuilt list ready to replace the live context; the remaining fields mirror
// CompactionEvent so a front-end can report the same before/after summary as a
// compaction.
type RebuildResult struct {
// Messages is the rebuilt context: a single summary/checkpoint message
// followed by the retained recent tail. When NoOp is true it is the original
// list, unchanged.
Messages agentcore.MessageList
// FromCheckpoint is true when the boundary came from a persisted checkpoint;
// false when the no-checkpoint fallback ran a lossy compaction.
FromCheckpoint bool
// Watermark is the boundary index used: the checkpoint watermark, or the
// compaction cut point in the fallback path.
Watermark int
// SummarizedCount is how many leading messages were collapsed into the summary.
SummarizedCount int
// KeptCount is how many recent messages were preserved verbatim.
KeptCount int
// TokensBefore / TokensAfter are the estimated context tokens before and after
// the rebuild (equal when NoOp).
TokensBefore int
TokensAfter int
// NoOp is true when nothing changed: no checkpoint existed and there was
// nothing to compact (an empty summarization range).
NoOp bool
}
// Event renders the rebuild as a CompactionEvent so consumers that already
// handle compaction reporting (the REPL/TUI event surfaces) can present a
// rebuild with the same shape. Reason is "rebuild".
func (r *RebuildResult) Event() agentcore.CompactionEvent {
return agentcore.CompactionEvent{
Reason: "rebuild",
TokensBefore: r.TokensBefore,
TokensAfter: r.TokensAfter,
SummarizedCount: r.SummarizedCount,
KeptCount: r.KeptCount,
}
}
// RebuildFromCheckpoint reconstructs the working context for sessionID.
//
// If a checkpoint exists under memoryRoot, the compression boundary is inserted
// at checkpoint.Watermark: messages before the watermark collapse to the
// checkpoint summary (rendered as a single compaction message), and messages
// at/after the watermark are preserved verbatim. The watermark is clamped to
// [0, len(msgs)] so a stale checkpoint recorded against a longer history (or one
// that has since been re-compacted) never slices out of range.
//
// If no checkpoint exists, it falls back to the lossy compaction path — the same
// compaction.Compact flow the loop's auto-compaction uses (runCompaction) — so
// /rebuild still shrinks the context. When there is nothing to compact the
// original list is returned with NoOp set.
//
// It performs no mutation of the caller's context and no checkpoint writes; the
// returned RebuildResult carries the rebuilt list for the caller to apply. When
// waitForCheckpoint is non-nil it is invoked before reading, so a caller that
// has an in-flight checkpoint write can block until it lands (kept as a callback
// so this stays decoupled from the loop's write path).
func RebuildFromCheckpoint(
ctx context.Context,
msgs agentcore.MessageList,
sessionID, memoryRoot string,
cfg *RunConfig,
waitForCheckpoint func(),
) (*RebuildResult, error) {
if waitForCheckpoint != nil {
waitForCheckpoint()
}
now := nowMillis()
tokensBefore := compaction.EstimateContextTokens(msgs).Tokens
cp, ok, err := LoadCheckpoint(sessionID, memoryRoot)
if err != nil {
return nil, err
}
if ok {
return rebuildFromLoadedCheckpoint(msgs, cp, tokensBefore, now), nil
}
// No checkpoint: fall back to the ordinary lossy compaction path.
res, err := runCompaction(ctx, msgs, cfg)
if err != nil {
return nil, err
}
if res == nil {
// Nothing to summarize (no valid cut point / empty range): leave as-is.
return &RebuildResult{
Messages: msgs,
TokensBefore: tokensBefore,
TokensAfter: tokensBefore,
KeptCount: len(msgs),
NoOp: true,
}, nil
}
rebuilt := res.RebuildContext(msgs, now)
kept := len(rebuilt) - 1
return &RebuildResult{
Messages: rebuilt,
FromCheckpoint: false,
Watermark: res.FirstKeptIndex,
SummarizedCount: len(msgs) - kept,
KeptCount: kept,
TokensBefore: tokensBefore,
TokensAfter: compaction.EstimateContextTokens(rebuilt).Tokens,
}, nil
}
// rebuildFromLoadedCheckpoint builds the rebuilt context from a loaded
// checkpoint: the pre-watermark prefix collapses to a single compaction message
// carrying cp.Summary, and the tail from the (clamped) watermark on is preserved
// verbatim. It reuses compaction.CompactionResult.RebuildContext so the summary
// message is shaped exactly like a compaction checkpoint.
func rebuildFromLoadedCheckpoint(msgs agentcore.MessageList, cp *Checkpoint, tokensBefore int, now int64) *RebuildResult {
w := cp.Watermark
if w < 0 {
w = 0
}
if w > len(msgs) {
w = len(msgs)
}
res := &compaction.CompactionResult{
Summary: cp.Summary,
FirstKeptIndex: w,
TokensBefore: tokensBefore,
}
rebuilt := res.RebuildContext(msgs, now)
kept := len(rebuilt) - 1
return &RebuildResult{
Messages: rebuilt,
FromCheckpoint: true,
Watermark: w,
SummarizedCount: w,
KeptCount: kept,
TokensBefore: tokensBefore,
TokensAfter: compaction.EstimateContextTokens(rebuilt).Tokens,
}
}
+194
View File
@@ -0,0 +1,194 @@
package runtime
// Tests for context rebuild (#482): with a checkpoint present, RebuildFromCheckpoint
// inserts the compression boundary at the watermark — collapsing the pre-watermark
// prefix into the checkpoint summary and preserving the recent tail verbatim; with
// no checkpoint it falls back to the lossy compaction path (compaction.Compact).
// Filesystem access uses t.TempDir(), matching checkpoint_test.go; the summary
// model is the shared summaryStream fake from compaction_test.go.
import (
"context"
"strings"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
)
// textUser builds a user message carrying body, used to seed a rebuildable history.
func textUser(body string) agentcore.UserMessage {
return agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(body)},
}
}
func TestRebuildFromCheckpoint_InsertsBoundary(t *testing.T) {
root := t.TempDir()
const sessionID = "sess-rebuild"
// A 6-message history; the checkpoint collapses the first 4 into a summary and
// keeps messages [4:] verbatim.
msgs := agentcore.MessageList{
textUser("m0"), textUser("m1"), textUser("m2"),
textUser("m3"), textUser("keep-A"), textUser("keep-B"),
}
cp := Checkpoint{
Watermark: 4,
Summary: "## Goal\ndistilled prefix",
CreatedAt: time.Now().UTC(),
CoveredMessages: 4,
}
if err := WriteCheckpoint(sessionID, root, cp); err != nil {
t.Fatalf("WriteCheckpoint: %v", err)
}
// No summarization stream is needed: the checkpoint path is pure and local.
cfg := newRunCfg(nil)
res, err := RebuildFromCheckpoint(context.Background(), msgs, sessionID, root, &cfg, nil)
if err != nil {
t.Fatalf("RebuildFromCheckpoint: %v", err)
}
if !res.FromCheckpoint {
t.Fatalf("expected FromCheckpoint=true")
}
if res.NoOp {
t.Fatalf("expected a real rebuild, got NoOp")
}
if res.Watermark != 4 || res.SummarizedCount != 4 {
t.Fatalf("watermark/summarized: got %d/%d, want 4/4", res.Watermark, res.SummarizedCount)
}
// Rebuilt list = 1 compaction message + the retained tail (2 messages).
if len(res.Messages) != 3 {
t.Fatalf("rebuilt length: got %d, want 3: %+v", len(res.Messages), res.Messages)
}
if res.KeptCount != 2 {
t.Fatalf("kept: got %d, want 2", res.KeptCount)
}
// The prefix must collapse into a single compaction message carrying the summary.
head, ok := res.Messages[0].(agentcore.CompactionMessage)
if !ok {
t.Fatalf("message[0] should be a compaction checkpoint, got %T", res.Messages[0])
}
if !strings.Contains(head.Summary, "distilled prefix") {
t.Fatalf("summary not carried through: %q", head.Summary)
}
// The recent tail is preserved verbatim, in order.
for i, want := range []string{"keep-A", "keep-B"} {
um, ok := res.Messages[i+1].(agentcore.UserMessage)
if !ok {
t.Fatalf("message[%d] should be preserved user message, got %T", i+1, res.Messages[i+1])
}
if got := agentcore.ContentToText(um.Content); got != want {
t.Fatalf("tail[%d]: got %q, want %q", i, got, want)
}
}
}
func TestRebuildFromCheckpoint_ClampsStaleWatermark(t *testing.T) {
root := t.TempDir()
const sessionID = "sess-stale"
msgs := agentcore.MessageList{textUser("a"), textUser("b")}
// Watermark past the end (history was re-compacted since the checkpoint).
cp := Checkpoint{Watermark: 99, Summary: "old summary", CreatedAt: time.Now().UTC()}
if err := WriteCheckpoint(sessionID, root, cp); err != nil {
t.Fatalf("WriteCheckpoint: %v", err)
}
cfg := newRunCfg(nil)
res, err := RebuildFromCheckpoint(context.Background(), msgs, sessionID, root, &cfg, nil)
if err != nil {
t.Fatalf("RebuildFromCheckpoint: %v", err)
}
// Clamped to len(msgs)=2: everything collapses, no verbatim tail, only the head.
if res.Watermark != 2 || res.KeptCount != 0 {
t.Fatalf("clamp: watermark=%d kept=%d, want 2/0", res.Watermark, res.KeptCount)
}
if len(res.Messages) != 1 {
t.Fatalf("rebuilt length: got %d, want 1 (summary only)", len(res.Messages))
}
}
func TestRebuildFromCheckpoint_WaitCallbackInvoked(t *testing.T) {
root := t.TempDir()
const sessionID = "sess-wait"
cp := Checkpoint{Watermark: 0, Summary: "s", CreatedAt: time.Now().UTC()}
if err := WriteCheckpoint(sessionID, root, cp); err != nil {
t.Fatalf("WriteCheckpoint: %v", err)
}
waited := false
cfg := newRunCfg(nil)
_, err := RebuildFromCheckpoint(context.Background(), agentcore.MessageList{textUser("x")}, sessionID, root, &cfg, func() { waited = true })
if err != nil {
t.Fatalf("RebuildFromCheckpoint: %v", err)
}
if !waited {
t.Fatalf("waitForCheckpoint callback was not invoked before reading")
}
}
func TestRebuildFromCheckpoint_FallsBackToCompaction(t *testing.T) {
root := t.TempDir() // empty: no checkpoint on disk
const sessionID = "sess-nocp"
// Seed a long history so FindCutPoint leaves a summarizable prefix.
msgs := bigUserMessages(12, 800)
cfg := newRunCfg(scriptedStream(nil))
cfg.SummaryStream = summaryStream("## Goal\nfallback compaction summary")
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
res, err := RebuildFromCheckpoint(context.Background(), msgs, sessionID, root, &cfg, nil)
if err != nil {
t.Fatalf("RebuildFromCheckpoint: %v", err)
}
if res.FromCheckpoint {
t.Fatalf("expected fallback (FromCheckpoint=false) when no checkpoint exists")
}
if res.NoOp {
t.Fatalf("expected a real compaction fallback, got NoOp")
}
if res.SummarizedCount <= 0 {
t.Fatalf("expected some messages summarized, got %d", res.SummarizedCount)
}
if res.TokensAfter >= res.TokensBefore {
t.Fatalf("fallback should reduce tokens: before=%d after=%d", res.TokensBefore, res.TokensAfter)
}
// The rebuilt context begins with a compaction checkpoint holding the summary.
head, ok := res.Messages[0].(agentcore.CompactionMessage)
if !ok {
t.Fatalf("message[0] should be a compaction checkpoint, got %T", res.Messages[0])
}
if !strings.Contains(head.Summary, "fallback compaction summary") {
t.Fatalf("fallback summary not carried through: %q", head.Summary)
}
}
func TestRebuildFromCheckpoint_FallbackNoOpWhenNothingToCompact(t *testing.T) {
root := t.TempDir() // no checkpoint
const sessionID = "sess-empty"
// A single short message: no valid cut point leaves a summarization range, so
// Compact returns (nil, nil) and the rebuild is a no-op.
msgs := agentcore.MessageList{textUser("only")}
cfg := newRunCfg(scriptedStream(nil))
cfg.SummaryStream = summaryStream("unused")
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
res, err := RebuildFromCheckpoint(context.Background(), msgs, sessionID, root, &cfg, nil)
if err != nil {
t.Fatalf("RebuildFromCheckpoint: %v", err)
}
if !res.NoOp {
t.Fatalf("expected NoOp when there is nothing to compact")
}
if len(res.Messages) != 1 || res.TokensAfter != res.TokensBefore {
t.Fatalf("no-op should return the original context unchanged: %+v", res)
}
}
+264
View File
@@ -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
}
+194
View File
@@ -0,0 +1,194 @@
package runtime
import (
"context"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/provider"
)
// wrapStreamCapture wraps a StreamFn to record every text-block message the
// request carried into seen, so a test can assert what the model was sent.
func wrapStreamCapture(inner provider.StreamFn, seen *[]string) provider.StreamFn {
return func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
for _, m := range llm.Messages {
if um, ok := m.(agentcore.UserMessage); ok {
*seen = append(*seen, agentcore.ContentToText(um.Content))
}
}
return inner(ctx, model, llm, cfg)
}
}
// reminderTextsInRequest drives one turn and returns the <system-reminder>
// message texts the provider stream actually received (the request-shaped list),
// so a test can assert what the model saw without touching persisted state.
func reminderTextsInRequest(t *testing.T, reg *ReminderRegistry, agentCtx *agentcore.AgentContext) []string {
t.Helper()
var seen []string
streamFn := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
// Wrap the stream so we can inspect the LlmContext it is handed.
cfg := newRunCfg(nil)
cfg.Reminders = reg
cfg.LoopConfig.Stream = wrapStreamCapture(streamFn, &seen)
collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
return seen
}
func TestWrapSystemReminderLabelsBackgroundContext(t *testing.T) {
out := WrapSystemReminder("body here")
if !strings.Contains(out, "<system-reminder>") || !strings.Contains(out, "</system-reminder>") {
t.Errorf("reminder must be wrapped in <system-reminder> tags, got %q", out)
}
if !strings.Contains(out, "NOT a message or instruction from the user") {
t.Errorf("reminder must be labeled as background context, not a user instruction, got %q", out)
}
if !strings.Contains(out, "body here") {
t.Errorf("reminder must contain the body, got %q", out)
}
}
func TestReminderInjectedWhenConditionHolds(t *testing.T) {
fired := ReminderFunc{NameField: "always", Fn: func(ctx context.Context, msgs agentcore.MessageList) (string, bool) {
return "budget is low", true
}}
reg := NewReminderRegistry(fired)
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
seen := reminderTextsInRequest(t, reg, agentCtx)
found := false
for _, s := range seen {
if strings.Contains(s, "budget is low") && strings.Contains(s, "<system-reminder>") {
found = true
}
}
if !found {
t.Fatalf("expected a system-reminder in the request, saw %v", seen)
}
// Ephemeral: the reminder must NOT be written back into the persisted history.
for _, m := range agentCtx.Messages {
if um, ok := m.(agentcore.UserMessage); ok {
if strings.Contains(agentcore.ContentToText(um.Content), "system-reminder") {
t.Errorf("reminder leaked into persisted message history: %+v", um)
}
}
}
}
func TestReminderNotInjectedWhenConditionFails(t *testing.T) {
silent := ReminderFunc{NameField: "never", Fn: func(ctx context.Context, msgs agentcore.MessageList) (string, bool) {
return "", false
}}
reg := NewReminderRegistry(silent)
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
seen := reminderTextsInRequest(t, reg, agentCtx)
for _, s := range seen {
if strings.Contains(s, "system-reminder") {
t.Errorf("no reminder should be injected when the provider declines, saw %q", s)
}
}
}
func TestReminderPreservesInnerTransform(t *testing.T) {
var innerRan bool
reg := NewReminderRegistry(ReminderFunc{NameField: "always", Fn: func(ctx context.Context, msgs agentcore.MessageList) (string, bool) {
return "note", true
}})
inner := func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList {
innerRan = true
return msgs
}
wrapped := reg.wrapTransform(inner)
out := wrapped(context.Background(), agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}})
if !innerRan {
t.Errorf("wrapTransform must call the inner TransformContext")
}
if len(out) != 2 {
t.Fatalf("expected original + 1 reminder message, got %d", len(out))
}
}
func TestTodoReminderProvider(t *testing.T) {
store := agenttool.NewTodoStore()
p := &TodoReminderProvider{Store: store}
// Empty store: silent.
if _, ok := p.Reminder(context.Background(), nil); ok {
t.Errorf("empty todo store must not fire a reminder")
}
// All completed: silent.
store.Set([]agenttool.TodoItem{{Content: "done", Status: agenttool.TodoCompleted}})
if _, ok := p.Reminder(context.Background(), nil); ok {
t.Errorf("fully-completed todo list must not fire a reminder")
}
// Incomplete work: fires with the rendered list.
store.Set([]agenttool.TodoItem{
{Content: "write code", Status: agenttool.TodoInProgress},
{Content: "review", Status: agenttool.TodoPending},
})
body, ok := p.Reminder(context.Background(), nil)
if !ok {
t.Fatalf("incomplete todo list must fire a reminder")
}
if !strings.Contains(body, "write code") || !strings.Contains(body, "review") {
t.Errorf("reminder body should render the todo items, got %q", body)
}
// nil store: never fires.
nilP := &TodoReminderProvider{}
if _, ok := nilP.Reminder(context.Background(), nil); ok {
t.Errorf("nil todo store must not fire a reminder")
}
}
func TestTodoReminderEndToEndInjection(t *testing.T) {
store := agenttool.NewTodoStore()
store.Set([]agenttool.TodoItem{{Content: "unfinished task", Status: agenttool.TodoInProgress}})
reg := NewReminderRegistry(&TodoReminderProvider{Store: store})
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
seen := reminderTextsInRequest(t, reg, agentCtx)
found := false
for _, s := range seen {
if strings.Contains(s, "unfinished task") && strings.Contains(s, "<system-reminder>") {
found = true
}
}
if !found {
t.Fatalf("todo reminder should be injected into the request, saw %v", seen)
}
}
func TestGoalReminderOnlyWhenActive(t *testing.T) {
st := agenttool.NewGoalState()
p := &GoalReminderProvider{State: st}
// Idle: no reminder.
if _, ok := p.Reminder(context.Background(), nil); ok {
t.Fatal("idle goal should not inject a reminder")
}
// Active: injects the objective.
st.Start("g1", "build the feature", 0)
body, ok := p.Reminder(context.Background(), nil)
if !ok {
t.Fatal("active goal should inject a reminder")
}
if !strings.Contains(body, "build the feature") {
t.Errorf("reminder body missing objective: %q", body)
}
// Completed: silent again.
st.MarkComplete("done")
if _, ok := p.Reminder(context.Background(), nil); ok {
t.Fatal("completed goal should not inject a reminder")
}
}
+86
View File
@@ -0,0 +1,86 @@
// This file implements DrainStream (architecture deepening ①): the single place
// that consumes a loop EventStream. The streaming-text delta accounting (track
// how many bytes of the current assistant message have been surfaced, emit only
// the new suffix) and the message_update-vs-turn_end dispatch were previously
// hand-rolled in three places — the REPL, the headless driver, and the
// sub-agent tool. Each consumer now supplies callbacks and shares one drain
// loop, so a bug in the delta arithmetic or the dispatch has exactly one home.
package runtime
import (
"context"
"github.com/smallnest/pigo/internal/agentcore"
)
// StreamHandler is the set of callbacks DrainStream invokes as it consumes a
// run's events. Every field is optional (nil = ignore that signal). Callbacks
// run on the draining goroutine, in event order.
type StreamHandler struct {
// OnText receives each new suffix of the streaming assistant text: the bytes
// produced since the last OnText call for the current turn. The final suffix
// is flushed at turn end before OnTurnEnd, so a consumer that only implements
// OnText still sees the complete text.
OnText func(delta string)
// OnTurnEnd fires once per completed turn, after the turn's text is fully
// flushed, carrying the final assistant message and the tool results produced
// during the turn. Consumers render tool activity here.
OnTurnEnd func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage)
// OnEvent, when set, receives every raw event before the typed callbacks —
// used by the stream-json protocol driver, which serialises the whole event.
OnEvent func(ev agentcore.AgentEvent)
}
// DrainStream consumes stream to completion, invoking h's callbacks, and returns
// the final assistant message (or nil) plus the run's result error. It always
// drains every event even if a callback has side effects that fail, so the
// loop's producer goroutine never blocks on back-pressure (the no-leak
// contract). The final assistant message is taken from the run result when
// available, falling back to the last turn_end message observed on the stream.
func DrainStream(ctx context.Context, stream *LoopEventStream, h StreamHandler) (*agentcore.AssistantMessage, error) {
// printed tracks how many bytes of the current streaming assistant message
// have already been surfaced via OnText, so each update emits only the delta.
printed := 0
var lastTurn *agentcore.AssistantMessage
emitText := func(text string) {
if len(text) > printed {
if h.OnText != nil {
h.OnText(text[printed:])
}
printed = len(text)
}
}
for ev := range stream.Events() {
if h.OnEvent != nil {
h.OnEvent(ev)
}
switch e := ev.(type) {
case agentcore.MessageUpdateEvent:
if a, ok := e.Message.(agentcore.AssistantMessage); ok {
emitText(agentcore.ContentToText(a.Content))
}
case agentcore.TurnEndEvent:
// Flush any tail the streaming updates did not cover (covers providers
// that only deliver the complete message at turn end), then reset for
// the next turn and hand the turn to the consumer.
emitText(agentcore.ContentToText(e.Message.Content))
printed = 0
m := e.Message
lastTurn = &m
if h.OnTurnEnd != nil {
h.OnTurnEnd(e.Message, e.ToolResults)
}
}
}
msgs, resErr := stream.Result(ctx)
if resErr != nil {
return lastTurn, resErr
}
if final := agentcore.LastAssistantOf(msgs); final != nil {
return final, nil
}
return lastTurn, nil
}
+164
View File
@@ -0,0 +1,164 @@
package runtime
// Tests for DrainStream (architecture deepening ①): the single event-stream
// consumer shared by the REPL, the headless driver, and the sub-agent tool.
// These drive it with a hand-built LoopEventStream so the delta accounting and
// the message_update-vs-turn_end dispatch are exercised directly, independent
// of any provider.
import (
"context"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// emitStream builds a LoopEventStream, runs emit on a producer goroutine to
// push events and set the result, and returns the stream ready to drain.
func emitStream(emit func(s *LoopEventStream)) *LoopEventStream {
s := agentcore.NewEventStream[agentcore.AgentEvent, []agentcore.AgentMessage](0)
go func() {
emit(s)
s.Close()
}()
return s
}
func assistantWith(text string) agentcore.AssistantMessage {
return agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
}
}
// TestDrainStreamTextDeltas verifies OnText receives only the new suffix of the
// streaming assistant message on each update, never re-emitting printed bytes.
func TestDrainStreamTextDeltas(t *testing.T) {
ctx := context.Background()
stream := emitStream(func(s *LoopEventStream) {
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("Hel")})
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("Hello")})
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("Hello world")})
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("Hello world")})
s.SetResult([]agentcore.AgentMessage{assistantWith("Hello world")})
})
var b strings.Builder
deltas := 0
final, err := DrainStream(ctx, stream, StreamHandler{
OnText: func(delta string) { b.WriteString(delta); deltas++ },
})
if err != nil {
t.Fatalf("DrainStream: %v", err)
}
if got := b.String(); got != "Hello world" {
t.Errorf("concatenated deltas = %q, want %q", got, "Hello world")
}
// 3 update deltas ("Hel","lo","<space>world"); turn-end adds nothing new.
if deltas != 3 {
t.Errorf("OnText calls = %d, want 3 (turn-end must not re-emit)", deltas)
}
if final == nil || agentcore.ContentToText(final.Content) != "Hello world" {
t.Errorf("final message = %v, want %q", final, "Hello world")
}
}
// TestDrainStreamTurnEndFlush verifies that when a provider delivers only the
// complete message at turn end (no streaming updates), OnText still receives the
// full text via the turn-end flush.
func TestDrainStreamTurnEndFlush(t *testing.T) {
ctx := context.Background()
stream := emitStream(func(s *LoopEventStream) {
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("complete only at end")})
s.SetResult([]agentcore.AgentMessage{assistantWith("complete only at end")})
})
var b strings.Builder
_, err := DrainStream(ctx, stream, StreamHandler{OnText: func(d string) { b.WriteString(d) }})
if err != nil {
t.Fatalf("DrainStream: %v", err)
}
if got := b.String(); got != "complete only at end" {
t.Errorf("flushed text = %q, want full message", got)
}
}
// TestDrainStreamResetsPerTurn verifies the delta accounting resets between
// turns, so a second turn's text is emitted from its own start rather than being
// masked by the first turn's printed offset.
func TestDrainStreamResetsPerTurn(t *testing.T) {
ctx := context.Background()
stream := emitStream(func(s *LoopEventStream) {
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("first")})
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("first")})
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("two")})
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("two")})
s.SetResult([]agentcore.AgentMessage{assistantWith("two")})
})
var b strings.Builder
turns := 0
_, err := DrainStream(ctx, stream, StreamHandler{
OnText: func(d string) { b.WriteString(d) },
OnTurnEnd: func(agentcore.AssistantMessage, []agentcore.ToolResultMessage) { turns++ },
})
if err != nil {
t.Fatalf("DrainStream: %v", err)
}
if got := b.String(); got != "firsttwo" {
t.Errorf("text across turns = %q, want %q", got, "firsttwo")
}
if turns != 2 {
t.Errorf("OnTurnEnd calls = %d, want 2", turns)
}
}
// TestDrainStreamOnTurnEndResults verifies tool results are handed to OnTurnEnd.
func TestDrainStreamOnTurnEndResults(t *testing.T) {
ctx := context.Background()
res := agentcore.ToolResultMessage{Content: agentcore.ContentList{agentcore.NewTextContent("42")}}
stream := emitStream(func(s *LoopEventStream) {
_ = s.Emit(ctx, agentcore.TurnEndEvent{
Message: assistantWith(""),
ToolResults: []agentcore.ToolResultMessage{res},
})
s.SetResult(nil)
})
var gotResults int
_, err := DrainStream(ctx, stream, StreamHandler{
OnTurnEnd: func(_ agentcore.AssistantMessage, rs []agentcore.ToolResultMessage) { gotResults = len(rs) },
})
if err != nil {
t.Fatalf("DrainStream: %v", err)
}
if gotResults != 1 {
t.Errorf("OnTurnEnd tool results = %d, want 1", gotResults)
}
}
// TestDrainStreamOnEvent verifies OnEvent sees every raw event in order (the
// stream-json driver's hook), and that a nil-callback handler still drains.
func TestDrainStreamOnEvent(t *testing.T) {
ctx := context.Background()
stream := emitStream(func(s *LoopEventStream) {
_ = s.Emit(ctx, agentcore.AgentStartEvent{})
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("x")})
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("x")})
_ = s.Emit(ctx, agentcore.AgentEndEvent{})
s.SetResult(nil)
})
var types []string
_, err := DrainStream(ctx, stream, StreamHandler{
OnEvent: func(ev agentcore.AgentEvent) { types = append(types, ev.EventType()) },
})
if err != nil {
t.Fatalf("DrainStream: %v", err)
}
want := []string{"agent_start", "message_update", "turn_end", "agent_end"}
if strings.Join(types, ",") != strings.Join(want, ",") {
t.Errorf("OnEvent order = %v, want %v", types, want)
}
}
+374
View File
@@ -0,0 +1,374 @@
// This file implements declarative skills (US-028, #45): a skill is a markdown
// file with a YAML frontmatter block (mirrors SKILL.md) that names a reusable
// capability. Loading a skill parses its metadata (name, description, optional
// tool allow-list and model) and its markdown body (the skill's system prompt),
// then materializes it as a sub-agent tool — so invoking a skill runs its body
// as the system prompt of a child agent loop, reusing the SubAgentTool
// abstraction rather than introducing a second execution path. Each skill's
// description is surfaced in the parent's capability list so the model can
// choose to delegate to it.
package runtime
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"gopkg.in/yaml.v3"
)
// SkillFrontmatter is the YAML metadata block at the head of a skill file.
type SkillFrontmatter struct {
// Name is the skill identifier; it becomes the spawnable tool name. When
// omitted it defaults to the file's base name (without extension).
Name string `yaml:"name"`
// Description tells the model what the skill does and when to use it. It is
// injected into the capability list, so it should be action-oriented.
Description string `yaml:"description"`
// AllowedTools optionally restricts the tools the skill's sub-agent may use,
// by tool name. Empty means "inherit the provided tool set as-is". Real
// Claude Code skills write this either as a YAML list or as a single
// scalar string (e.g. "Bash(foo:*), Read"), so it tolerates both forms.
AllowedTools stringList `yaml:"allowed-tools"`
// Model optionally pins the skill to a specific model; empty inherits.
Model string `yaml:"model"`
// DisableModelInvocation, when true, keeps the skill out of the system
// prompt's <available_skills> list so the model cannot auto-invoke it; it
// remains reachable only via its explicit "/name" slash command (mirrors pi's
// disable-model-invocation frontmatter key). Defaults to false.
DisableModelInvocation bool `yaml:"disable-model-invocation"`
}
// Agent Skills spec limits (mirrors pi/agentskills.io): a skill name is a short
// slug and a description is a single sentence, both bounded so they stay cheap
// to inject into the system prompt.
const (
maxSkillNameLength = 64
maxSkillDescriptionLength = 1024
)
// skillNamePattern matches a valid skill name per the Agent Skills spec:
// lowercase ASCII letters, digits, and hyphens only.
var skillNamePattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// validateSkillName reports why name violates the Agent Skills spec, or nil
// when it is valid: lowercase a-z/0-9/hyphen only, at most maxSkillNameLength
// characters, and no leading, trailing, or consecutive hyphens.
func validateSkillName(name string) error {
if len(name) > maxSkillNameLength {
return fmt.Errorf("name exceeds %d characters (%d)", maxSkillNameLength, len(name))
}
if !skillNamePattern.MatchString(name) {
return fmt.Errorf("name %q contains invalid characters (allowed: lowercase a-z, 0-9, hyphen)", name)
}
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return fmt.Errorf("name %q must not start or end with a hyphen", name)
}
if strings.Contains(name, "--") {
return fmt.Errorf("name %q must not contain consecutive hyphens", name)
}
return nil
}
// validateSkillDescription reports why description violates the spec, or nil
// when it is valid: non-empty and at most maxSkillDescriptionLength characters.
func validateSkillDescription(description string) error {
if strings.TrimSpace(description) == "" {
return errors.New("frontmatter missing required 'description'")
}
if len(description) > maxSkillDescriptionLength {
return fmt.Errorf("description exceeds %d characters (%d)", maxSkillDescriptionLength, len(description))
}
return nil
}
// stringList is a []string that unmarshals from either a YAML sequence
// (- a\n- b) or a single scalar. A scalar is split on commas so the common
// Claude Code form `allowed-tools: Bash(foo:*), Read` parses into two entries.
// This tolerance matters: a strict []string field rejects the scalar form and,
// because LoadSkillsDir aborts on the first parse error, one such skill would
// hide every other skill in the directory.
type stringList []string
// UnmarshalYAML accepts a scalar or a sequence node.
func (l *stringList) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var s string
if err := node.Decode(&s); err != nil {
return err
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
*l = out
return nil
case yaml.SequenceNode:
var ss []string
if err := node.Decode(&ss); err != nil {
return err
}
*l = ss
return nil
default:
// An empty/null node leaves the list nil (no restriction).
return nil
}
}
// Skill is a parsed skill file: its metadata plus the markdown body that serves
// as the sub-agent's system prompt.
type Skill struct {
Frontmatter SkillFrontmatter
// Body is the markdown after the frontmatter block — the skill's instructions,
// used as the child agent's system prompt.
Body string
// Path is the source file, retained for diagnostics.
Path string
}
// ParseSkill parses a skill's raw file content into a Skill. The file must open
// with a YAML frontmatter block delimited by lines containing only "---"; the
// remainder is the markdown body. A missing or malformed frontmatter block is
// an error, since name/description drive discovery.
func ParseSkill(path string, content []byte) (*Skill, error) {
fm, body, err := splitFrontmatter(content)
if err != nil {
return nil, fmt.Errorf("skill %s: %w", path, err)
}
var meta SkillFrontmatter
if err := yaml.Unmarshal(fm, &meta); err != nil {
return nil, fmt.Errorf("skill %s: parse frontmatter: %w", path, err)
}
if meta.Name == "" {
base := filepath.Base(path)
meta.Name = strings.TrimSuffix(base, filepath.Ext(base))
}
if err := validateSkillName(meta.Name); err != nil {
return nil, fmt.Errorf("skill %s: %w", path, err)
}
if err := validateSkillDescription(meta.Description); err != nil {
return nil, fmt.Errorf("skill %s: %w", path, err)
}
return &Skill{Frontmatter: meta, Body: strings.TrimSpace(string(body)), Path: path}, nil
}
// splitFrontmatter separates a leading "---"-delimited YAML block from the rest
// of the document. It returns the frontmatter bytes (without the fences) and
// the remaining body. It errors if the document does not open with a fence or
// the closing fence is missing.
func splitFrontmatter(content []byte) (frontmatter, body []byte, err error) {
text := string(content)
// Tolerate a UTF-8 BOM and leading blank lines before the opening fence.
text = strings.TrimPrefix(text, "\ufeff")
trimmed := strings.TrimLeft(text, "\r\n")
if !strings.HasPrefix(trimmed, "---") {
return nil, nil, fmt.Errorf("missing YAML frontmatter (file must start with '---')")
}
lines := strings.Split(trimmed, "\n")
// lines[0] is the opening fence. Find the closing fence.
var fmLines []string
closeIdx := -1
for i := 1; i < len(lines); i++ {
if strings.TrimRight(lines[i], "\r") == "---" {
closeIdx = i
break
}
fmLines = append(fmLines, lines[i])
}
if closeIdx == -1 {
return nil, nil, fmt.Errorf("unterminated YAML frontmatter (missing closing '---')")
}
bodyLines := lines[closeIdx+1:]
return []byte(strings.Join(fmLines, "\n")), []byte(strings.Join(bodyLines, "\n")), nil
}
// LoadSkillsDir loads every "*.md" skill file in dir (non-recursively) plus any
// "<name>/SKILL.md" nested layout (mirrors the SKILL.md convention). It returns the
// parsed skills sorted by name. A missing directory yields no skills and no
// error (skills are optional).
//
// A malformed skill file does NOT abort the load: the file is skipped and its
// error accumulated, so one bad skill cannot hide every other skill in the
// directory (a real ~/.agents/skills holds 100+ skills authored to varying
// conventions). The successfully parsed skills are always returned; the error,
// when non-nil, joins every skip reason for the caller to surface as a
// non-fatal warning.
func LoadSkillsDir(dir string) ([]*Skill, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read skills dir %s: %w", dir, err)
}
var skills []*Skill
var errs []error
for _, e := range entries {
var path string
switch {
case e.IsDir():
// Nested layout: <dir>/<name>/SKILL.md.
candidate := filepath.Join(dir, e.Name(), "SKILL.md")
if _, statErr := os.Stat(candidate); statErr != nil {
continue
}
path = candidate
case strings.EqualFold(filepath.Ext(e.Name()), ".md"):
path = filepath.Join(dir, e.Name())
default:
continue
}
content, readErr := os.ReadFile(path)
if readErr != nil {
errs = append(errs, fmt.Errorf("read skill %s: %w", path, readErr))
continue
}
skill, parseErr := ParseSkill(path, content)
if parseErr != nil {
errs = append(errs, parseErr)
continue
}
skills = append(skills, skill)
}
sort.Slice(skills, func(i, j int) bool {
return skills[i].Frontmatter.Name < skills[j].Frontmatter.Name
})
return skills, errors.Join(errs...)
}
// SubAgentSpec turns a skill into a sub-agent spec: the skill body becomes the
// child's system prompt, the description is surfaced to the model, and the tool
// set is the provided tools filtered by AllowedTools (when set). newRunConfig
// builds each child run's configuration; it receives the resolved tool set so
// the caller can wire a matching registry.
func (s *Skill) SubAgentSpec(tools []agentcore.AgentTool, newRunConfig func(tools []agentcore.AgentTool) RunConfig) SubAgentSpec {
resolved := filterToolsByName(tools, s.Frontmatter.AllowedTools)
return SubAgentSpec{
Name: s.Frontmatter.Name,
Description: s.Frontmatter.Description,
SystemPrompt: s.Body,
Tools: resolved,
NewRunConfig: func() RunConfig { return newRunConfig(resolved) },
}
}
// SkillTool materializes a skill as an invocable sub-agent tool.
func (s *Skill) SkillTool(tools []agentcore.AgentTool, newRunConfig func(tools []agentcore.AgentTool) RunConfig) *SubAgentTool {
return NewSubAgentTool(s.SubAgentSpec(tools, newRunConfig))
}
// SlashCommand exposes the skill as a "/name" slash command (mirrors Claude Code's
// /skill-name invocation). Invoking it expands to the skill's instructions (its
// markdown body) as the prompt, with any arguments appended, so the skill runs
// in the current conversation. It is a prompt command (not an action): the
// expanded text is fed to the agent loop as the next user turn.
func (s *Skill) SlashCommand() SlashCommand {
body := s.Body
return SlashCommand{
Name: s.Frontmatter.Name,
Description: s.Frontmatter.Description,
Source: SourceUser,
Expand: func(args string) string {
if strings.Contains(body, "$ARGUMENTS") {
return strings.ReplaceAll(body, "$ARGUMENTS", args)
}
if strings.TrimSpace(args) == "" {
return body
}
return body + "\n\n" + args
},
}
}
// FormatSkillsForPrompt renders the visible skills as an <available_skills>
// XML block for injection into the system prompt (mirrors pi's
// formatSkillsForPrompt). It implements progressive disclosure: only each
// skill's name, description, and location (the absolute SKILL.md path) are
// listed, so the model can read the file on demand rather than carrying every
// skill body in context.
//
// Skills with DisableModelInvocation == true are excluded (they remain
// reachable only via their explicit "/name" slash command). When no visible
// skill remains, it returns the empty string so callers can append
// unconditionally without altering a skill-free prompt.
func FormatSkillsForPrompt(skills []*Skill) string {
visible := make([]*Skill, 0, len(skills))
for _, s := range skills {
if s != nil && !s.Frontmatter.DisableModelInvocation {
visible = append(visible, s)
}
}
if len(visible) == 0 {
return ""
}
lines := []string{
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
}
for _, s := range visible {
lines = append(lines,
" <skill>",
" <name>"+escapeXML(s.Frontmatter.Name)+"</name>",
" <description>"+escapeXML(s.Frontmatter.Description)+"</description>",
" <location>"+escapeXML(skillLocation(s.Path))+"</location>",
" </skill>",
)
}
lines = append(lines, "</available_skills>")
return strings.Join(lines, "\n")
}
// skillLocation returns the absolute path to a skill file so the model can load
// it with the read tool regardless of the working directory. It falls back to
// the original path if resolution fails.
func skillLocation(path string) string {
if abs, err := filepath.Abs(path); err == nil {
return abs
}
return path
}
// escapeXML escapes the five XML special characters so a skill's name or
// description cannot break the surrounding markup.
func escapeXML(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
return r.Replace(s)
}
// filterToolsByName keeps only tools whose Name is in allow. An empty allow
// list means "no restriction" and returns the input unchanged.
func filterToolsByName(tools []agentcore.AgentTool, allow []string) []agentcore.AgentTool {
if len(allow) == 0 {
return tools
}
set := make(map[string]bool, len(allow))
for _, n := range allow {
set[n] = true
}
out := make([]agentcore.AgentTool, 0, len(tools))
for _, t := range tools {
if set[t.Name()] {
out = append(out, t)
}
}
return out
}
+538
View File
@@ -0,0 +1,538 @@
// This file implements slash-commands (US-029, #45): typed "/name" shortcuts a
// user invokes in the REPL. There are two sources, resolved with a fixed
// priority:
//
// - Built-in commands are registered at compile time via RegisterBuiltin
// (from init() in the fork's own code). They are always available.
// - User commands are declarative markdown templates loaded from a directory
// (mirrors the .../commands/*.md convention): the file name is the command
// name and the body is a prompt template that may reference $ARGUMENTS.
//
// Conflict rule: same-name commands resolve by priority tier (built-in >
// project > global > package > settings > CLI); the higher tier wins and the
// loser is reported via Shadowed. Built-ins are load-bearing and always win.
// Within a tier, the last-added command overrides earlier ones (a re-load).
//
// There is deliberately no standalone plugin mechanism: a fork adds built-ins
// via init() registration, and external extensions go through MCP (deferred).
package runtime
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// SlashCommandSource identifies where a command came from, used for the
// conflict/priority rule and for display.
type SlashCommandSource int
const (
// SourceBuiltin is a compile-time registered command (highest priority).
SourceBuiltin SlashCommandSource = iota
// SourceUser is a declarative markdown command template loaded from disk
// (e.g. ~/.pigo/commands/*.md).
SourceUser
// SourceSkill is a skill loaded from ~/.agents/skills and surfaced as a
// /skill-name command. It behaves like SourceUser for the built-in-wins
// conflict rule; the finer tag is for display only (e.g. /status).
SourceSkill
// SourcePlugin is a command declared by a loaded plugin. It behaves like
// SourceUser for the built-in-wins conflict rule; the finer tag is for
// display only.
SourcePlugin
)
func (s SlashCommandSource) String() string {
switch s {
case SourceBuiltin:
return "builtin"
case SourceSkill:
return "skill"
case SourcePlugin:
return "plugin"
default:
return "user"
}
}
// Tier is the priority tier of a command, used to resolve same-name conflicts
// across sources (mirrors pi prompt-templates discovery priority). Higher tiers
// win; the loser is recorded in Shadowed. Within the same tier the last-added
// command wins (a re-load overrides). Skills and plugins are treated as
// Global-tier for priority - their finer Source label is for display only.
type Tier int
const (
// Tier values are ordered lowest-to-highest priority: in a same-name
// conflict the higher Tier value wins, so TierBuiltin always wins and
// TierCLI always loses. Declared ascending so the natural > comparison
// matches "higher priority wins".
TierCLI Tier = iota
// TierSettings is a prompt template referenced by the config.toml prompts array.
TierSettings
// TierPackage is a prompt template discovered from an installed package
// source (distinct from one copied into the global dir).
TierPackage
// TierGlobal is a global user prompt template (e.g. ~/.pigo/prompts or the
// legacy ~/.pigo/commands); also the tier used for skills and plugins.
TierGlobal
// TierProject is a project-local prompt template (e.g. .pigo/prompts).
TierProject
// TierBuiltin is a compile-time or instance built-in command (highest).
TierBuiltin
)
func (t Tier) String() string {
switch t {
case TierBuiltin:
return "builtin"
case TierProject:
return "project"
case TierGlobal:
return "global"
case TierPackage:
return "package"
case TierSettings:
return "settings"
case TierCLI:
return "cli"
default:
return "unknown"
}
}
// ShadowedEntry records a command that lost a same-name conflict to a higher-
// tier command, for diagnostics. It carries the loser's name, tier, and source
// label so /help and the startup warning can say which source was shadowed.
type ShadowedEntry struct {
Name string
Tier Tier
Source SlashCommandSource
}
// String renders a shadowed entry as "name (tier)" for log lines.
func (e ShadowedEntry) String() string { return fmt.Sprintf("%s (%s)", e.Name, e.Tier) }
// SlashCommand is a resolved command: its name (without the leading "/"), a
// short description for the command palette, and its source. A command is one
// of three kinds, distinguished by which callback is set:
//
// - A prompt command sets Expand: it turns the invocation arguments into the
// prompt text fed to the agent (the original slash-command behavior).
// - An action command sets Action instead: it performs a side effect (e.g.
// switching the runtime model) and returns a status line to show the user,
// rather than producing a prompt. No agent run is started.
// - A hybrid command sets Run: it performs a side effect AND may return prompt
// text to run — used by plugin commands, which RPC their plugin, surface the
// returned notifications, then inject the returned prompt as the next turn.
//
// Exactly one of Expand/Action/Run should be set. Precedence when more than one
// is set: Action wins over Run, which wins over Expand. This split is what lets
// a control command like "/model" change runtime state — the old design could
// only emit prompt text.
type SlashCommand struct {
Name string
Description string
// ArgumentHint is an optional frontmatter hint shown before the description
// in autocomplete (e.g. "<PR-URL>"). Convention: <angle> for required args,
// [square] for optional. Empty when not set; display-only, not enforced.
ArgumentHint string
Source SlashCommandSource
// Tier is the priority tier used to resolve same-name conflicts across
// sources (built-in > project > global > package > settings > CLI). It is
// set by the AddX method matching the command's source; callers should not
// set it directly.
Tier Tier
// Expand maps the argument string (everything after "/name ") to the prompt
// text the command produces. For a built-in it may be arbitrary Go; for a
// user template it substitutes $ARGUMENTS into the markdown body. Nil for an
// action command.
Expand func(args string) string
// Action performs a side effect for the invocation and returns a status
// message to display (may be empty). Set instead of Expand for a control
// command like "/model". Because it is an arbitrary Go closure it can capture
// and mutate live runtime state, which Expand (a pure prompt producer)
// cannot. Nil for a prompt command.
Action func(args string) string
// Run is the hybrid of Action and Expand: it performs a side effect AND may
// produce prompt text to run as the next agent turn. It returns
// (message, prompt): message is shown to the user immediately (like an
// Action's status, e.g. plugin notifications), and prompt, when non-empty, is
// run as a normal turn (like Expand's output). This is what a plugin command
// needs — it RPCs its plugin (side effect), surfaces the returned
// notifications (message), then injects the returned prompt (prompt). Set
// instead of Expand/Action for such a command; nil otherwise. When Run is set
// it takes precedence over Expand (but Action still wins over Run).
Run func(args string) (message, prompt string)
}
// SlashKind classifies how a resolved invocation should be handled by the
// caller: run its prompt through the agent, or treat it as a completed action.
type SlashKind int
const (
// SlashPrompt means the outcome carries prompt text to run (or, when not a
// command at all, the verbatim input).
SlashPrompt SlashKind = iota
// SlashAction means an action command already ran; the outcome carries only
// a status Message and no agent run should start.
SlashAction
)
// SlashOutcome is the structured result of resolving one input line. Handled is
// false when the input was not a slash command (Prompt holds the verbatim input
// to run). When Handled is true, Kind says whether Prompt should be run
// (SlashPrompt) or an action already ran and Message should be shown without
// starting a run (SlashAction).
//
// A hybrid (Run) command resolves to Kind SlashPrompt with BOTH fields set: its
// side effect already ran, Message carries the text to show the user first
// (e.g. plugin notifications), and Prompt, when non-empty, is the turn to run
// after. The caller shows Message (if any) then runs Prompt (if non-empty).
type SlashOutcome struct {
Handled bool
Kind SlashKind
Prompt string
Message string
}
// builtinCommands holds compile-time registered commands, keyed by name. It is
// populated by RegisterBuiltin from init() and read when building a registry.
//
// Concurrency contract: this global is written only by RegisterBuiltin, which
// must be called from init() (single-threaded, before main), and read only
// afterwards by NewSlashRegistry. It carries no lock because that init-only
// discipline means there is never a concurrent write; do not call
// RegisterBuiltin after startup.
var builtinCommands = map[string]SlashCommand{}
// RegisterBuiltin registers a built-in slash command at compile time. It is
// intended to be called from init(); a duplicate name panics, since two
// built-ins claiming the same name is a programming error in the fork.
func RegisterBuiltin(cmd SlashCommand) {
if cmd.Name == "" {
panic("agent: RegisterBuiltin with empty name")
}
if _, exists := builtinCommands[cmd.Name]; exists {
panic(fmt.Sprintf("agent: duplicate built-in slash command %q", cmd.Name))
}
cmd.Source = SourceBuiltin
cmd.Tier = TierBuiltin
builtinCommands[cmd.Name] = cmd
}
// SlashRegistry resolves "/name" invocations against built-in and user
// commands, applying the built-in-wins priority rule.
type SlashRegistry struct {
commands map[string]SlashCommand
// shadowed records commands that lost a same-name conflict to a higher-tier
// command, with their tier and source for diagnostics. Same-tier overrides
// (last-write-wins) are not recorded.
shadowed []ShadowedEntry
}
// NewSlashRegistry builds a registry seeded with all registered built-ins.
func NewSlashRegistry() *SlashRegistry {
r := &SlashRegistry{commands: make(map[string]SlashCommand, len(builtinCommands))}
for name, cmd := range builtinCommands {
r.commands[name] = cmd
}
return r
}
// AddBuiltin installs a built-in command directly on this registry instance,
// bypassing the compile-time global. It exists for action commands whose
// closure must capture live, per-run state (e.g. a model controller created in
// main) — such state cannot be reached from an init()-time RegisterBuiltin. The
// command is marked SourceBuiltin so it wins over a same-named user command,
// exactly like a globally registered built-in. A duplicate name panics, since
// two built-ins claiming one name is a programming error.
func (r *SlashRegistry) AddBuiltin(cmd SlashCommand) {
if cmd.Name == "" {
panic("agent: AddBuiltin with empty name")
}
if existing, ok := r.commands[cmd.Name]; ok && existing.Source == SourceBuiltin {
panic(fmt.Sprintf("agent: duplicate built-in slash command %q", cmd.Name))
}
cmd.Source = SourceBuiltin
cmd.Tier = TierBuiltin
r.add(cmd)
}
// AddUser installs a user command (TierGlobal), e.g. a prompt template from
// ~/.pigo/prompts or the legacy ~/.pigo/commands. A same-named built-in or
// project-tier command wins; same-tier (global) adds override silently.
func (r *SlashRegistry) AddUser(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierGlobal
r.add(cmd)
}
// AddSkill installs a skill command (loaded from ~/.agents/skills) at TierGlobal.
// It follows the same tier rule as AddUser - a built-in or project-tier command
// wins - only the source tag differs, so /status can report skills separately.
func (r *SlashRegistry) AddSkill(cmd SlashCommand) {
cmd.Source = SourceSkill
cmd.Tier = TierGlobal
r.add(cmd)
}
// AddPlugin installs a plugin-declared command at TierGlobal, mirroring AddUser
// with a SourcePlugin tag for display.
func (r *SlashRegistry) AddPlugin(cmd SlashCommand) {
cmd.Source = SourcePlugin
cmd.Tier = TierGlobal
r.add(cmd)
}
// Shadowed returns the commands that lost a same-name conflict to a higher-tier
// command, with their tier and source for diagnostics. Same-tier overrides
// (last-write-wins) are not recorded here.
func (r *SlashRegistry) Shadowed() []ShadowedEntry { return r.shadowed }
// AddProject installs a project-local prompt template (TierProject), which
// overrides a same-named global/package/settings/CLI template but loses to a
// built-in.
func (r *SlashRegistry) AddProject(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierProject
r.add(cmd)
}
// AddPackage installs a package-discovered prompt template (TierPackage).
func (r *SlashRegistry) AddPackage(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierPackage
r.add(cmd)
}
// AddSettings installs a prompt template referenced by config.toml (TierSettings).
func (r *SlashRegistry) AddSettings(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierSettings
r.add(cmd)
}
// AddCLI installs a prompt template referenced by --prompt-template (TierCLI,
// the lowest priority).
func (r *SlashRegistry) AddCLI(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierCLI
r.add(cmd)
}
// add installs cmd with tier-based conflict resolution. If a same-named command
// already exists, the higher tier wins and the loser is appended to shadowed;
// within the same tier the new command replaces the old (last-write-wins, no
// shadow entry). A built-in always wins because TierBuiltin is highest.
func (r *SlashRegistry) add(cmd SlashCommand) {
existing, ok := r.commands[cmd.Name]
if !ok {
r.commands[cmd.Name] = cmd
return
}
switch {
case existing.Tier > cmd.Tier:
// New command is lower tier: it loses and is shadowed.
r.shadowed = append(r.shadowed, ShadowedEntry{Name: cmd.Name, Tier: cmd.Tier, Source: cmd.Source})
case existing.Tier < cmd.Tier:
// New command is higher tier: it wins; the old one is shadowed.
r.shadowed = append(r.shadowed, ShadowedEntry{Name: existing.Name, Tier: existing.Tier, Source: existing.Source})
r.commands[cmd.Name] = cmd
default:
// Same tier: last-write-wins (a re-load), no shadow entry.
r.commands[cmd.Name] = cmd
}
}
// Lookup returns the command bound to name (without the leading "/").
func (r *SlashRegistry) Lookup(name string) (SlashCommand, bool) {
cmd, ok := r.commands[name]
return cmd, ok
}
// List returns all commands sorted by name.
func (r *SlashRegistry) List() []SlashCommand {
out := make([]SlashCommand, 0, len(r.commands))
for _, c := range r.commands {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Resolve parses a raw input line and, if it is a slash-command invocation,
// expands it to the prompt text the agent should run. It returns (prompt, true)
// when input begins with "/" and names a known PROMPT command; (input, false)
// when the input is not a slash command (the caller runs it verbatim); and an
// error when input is a "/name" for an unknown command.
//
// This is the legacy string API, kept for callers that only handle prompt
// commands. It reports an action command as handled with an empty prompt (the
// action does NOT run here) — callers that want action commands to execute must
// use ResolveOutcome instead.
func (r *SlashRegistry) Resolve(input string) (prompt string, handled bool, err error) {
out, err := r.ResolveOutcome(input)
if err != nil {
return "", false, err
}
return out.Prompt, out.Handled, nil
}
// ResolveOutcome parses a raw input line into a structured SlashOutcome. For a
// non-command it returns {Handled:false, Prompt:input}. For a known prompt
// command it returns {Handled:true, Kind:SlashPrompt, Prompt:<expanded>}. For a
// known action command it RUNS the action and returns {Handled:true,
// Kind:SlashAction, Message:<status>} — no prompt to run. For a known hybrid
// (Run) command it RUNS the side effect and returns {Handled:true,
// Kind:SlashPrompt, Message:<status>, Prompt:<text>} — the caller shows Message
// then runs Prompt when non-empty. An unknown "/name" yields an error.
func (r *SlashRegistry) ResolveOutcome(input string) (SlashOutcome, error) {
trimmed := strings.TrimLeft(input, " \t")
if !strings.HasPrefix(trimmed, "/") {
return SlashOutcome{Handled: false, Kind: SlashPrompt, Prompt: input}, nil
}
rest := trimmed[1:]
name := rest
args := ""
if i := strings.IndexAny(rest, " \t"); i >= 0 {
name = rest[:i]
args = strings.TrimSpace(rest[i+1:])
}
cmd, ok := r.commands[name]
if !ok {
return SlashOutcome{}, fmt.Errorf("unknown command %q", "/"+name)
}
if cmd.Action != nil {
return SlashOutcome{Handled: true, Kind: SlashAction, Message: cmd.Action(args)}, nil
}
if cmd.Run != nil {
// A hybrid command runs its side effect now and may yield prompt text.
// The outcome is a prompt (SlashPrompt) that also carries a Message to
// surface first; the caller shows Message then runs Prompt if non-empty.
message, prompt := cmd.Run(args)
return SlashOutcome{Handled: true, Kind: SlashPrompt, Message: message, Prompt: prompt}, nil
}
return SlashOutcome{Handled: true, Kind: SlashPrompt, Prompt: cmd.Expand(args)}, nil
}
// firstNonEmptyLine returns the first line of s whose trimmed form is non-empty,
// itself trimmed. It is the description fallback for templates whose frontmatter
// omits a description (mirrors pi: "If missing, the first non-empty line is used").
func firstNonEmptyLine(s string) string {
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
return t
}
}
return ""
}
// LoadPromptFile loads a single prompt-template file. The command name is the
// filename without its extension (e.g. /x/review.md -> "review"). It is the
// single-file counterpart of LoadUserCommandsDir, used for settings/CLI paths
// that point at one file rather than a directory.
func LoadPromptFile(path string) (SlashCommand, error) {
content, err := os.ReadFile(path)
if err != nil {
return SlashCommand{}, fmt.Errorf("read prompt %s: %w", path, err)
}
base := filepath.Base(path)
name := strings.TrimSuffix(base, filepath.Ext(base))
return ParseUserCommand(name, content)
}
// LoadUserCommandsDir loads declarative markdown command templates from dir
// (non-recursively). Each "*.md" file defines a command named after the file
// (without extension). The file may carry an optional YAML frontmatter block
// with a "description" (mirrors skills); the remaining body is the prompt template,
// expanded via ExpandTemplate at invoke time. A missing directory yields no
// commands and no error.
func LoadUserCommandsDir(dir string) ([]SlashCommand, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read commands dir %s: %w", dir, err)
}
var cmds []SlashCommand
for _, e := range entries {
if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".md") {
continue
}
path := filepath.Join(dir, e.Name())
content, readErr := os.ReadFile(path)
if readErr != nil {
return nil, fmt.Errorf("read command %s: %w", path, readErr)
}
name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
cmd, parseErr := ParseUserCommand(name, content)
if parseErr != nil {
return nil, parseErr
}
cmds = append(cmds, cmd)
}
sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name })
return cmds, nil
}
// ParseUserCommand parses a declarative command template. An optional YAML
// frontmatter block supplies a description; the body is the prompt template,
// expanded at invoke time via ExpandTemplate (positional $N, $@/$ARGUMENTS,
// ${1:-default}, ${@:N}). If arg tokenization fails (e.g. an unterminated
// quote) the raw arg string is used as $ARGUMENTS so the invocation still works.
func ParseUserCommand(name string, content []byte) (SlashCommand, error) {
body := string(content)
description := ""
hint := ""
// Reuse the skills frontmatter splitter when a fence is present; otherwise
// treat the whole file as the template body.
if strings.HasPrefix(strings.TrimLeft(strings.TrimPrefix(body, "\ufeff"), "\r\n"), "---") {
fm, rest, splitErr := splitFrontmatter(content)
if splitErr != nil {
return SlashCommand{}, fmt.Errorf("command %s: %w", name, splitErr)
}
var meta struct {
Description string `yaml:"description"`
Name string `yaml:"name"`
ArgumentHint string `yaml:"argument-hint"`
}
if err := yaml.Unmarshal(fm, &meta); err != nil {
return SlashCommand{}, fmt.Errorf("command %s: parse frontmatter: %w", name, err)
}
description = meta.Description
hint = meta.ArgumentHint
if meta.Name != "" {
name = meta.Name
}
body = string(rest)
}
// When the frontmatter omits a description, fall back to the first non-empty
// line of the body (\u5bf9\u6807 pi: "If missing, the first non-empty line is used").
if description == "" {
description = firstNonEmptyLine(body)
}
template := strings.TrimSpace(body)
return SlashCommand{
Name: name,
Description: description,
ArgumentHint: hint,
Source: SourceUser,
Expand: func(args string) string {
tokens, err := SplitArgs(args)
if err != nil {
// Split failure (e.g. an unterminated quote): treat the raw arg
// string as a single $ARGUMENTS rather than feeding a malformed
// arg list to the engine, so a bad invocation stays usable.
tokens = []string{args}
}
return ExpandTemplate(template, tokens)
},
}, nil
}
+182
View File
@@ -0,0 +1,182 @@
package runtime
// Tests for tiered priority resolution (US-009, #335): same-name commands
// across sources resolve by tier (built-in > project > global > package >
// settings > CLI), the loser is shadowed with its tier recorded, and same-tier
// adds override silently (last-write-wins, no shadow entry).
import "testing"
// TestSlashTierProjectOverridesGlobal: a project-tier template added after a
// global one wins; the global entry is shadowed with TierGlobal.
func TestSlashTierProjectOverridesGlobal(t *testing.T) {
r := NewSlashRegistry()
r.AddUser(SlashCommand{Name: "t", Expand: func(string) string { return "global" }})
r.AddProject(SlashCommand{Name: "t", Expand: func(string) string { return "project" }})
cmd, ok := r.Lookup("t")
if !ok {
t.Fatalf("command %q not found", "t")
}
if got := cmd.Expand(""); got != "project" {
t.Errorf("project must override global, got %q", got)
}
sh := r.Shadowed()
if len(sh) != 1 || sh[0].Name != "t" || sh[0].Tier != TierGlobal {
t.Errorf("global should be shadowed, got %v", sh)
}
}
// TestSlashTierGlobalOverridesSettings: a global template added after a
// settings-tier one wins; the settings entry is shadowed with TierSettings.
func TestSlashTierGlobalOverridesSettings(t *testing.T) {
r := NewSlashRegistry()
r.AddSettings(SlashCommand{Name: "t", Expand: func(string) string { return "settings" }})
r.AddUser(SlashCommand{Name: "t", Expand: func(string) string { return "global" }})
cmd, ok := r.Lookup("t")
if !ok {
t.Fatalf("command %q not found", "t")
}
if got := cmd.Expand(""); got != "global" {
t.Errorf("global must override settings, got %q", got)
}
sh := r.Shadowed()
if len(sh) != 1 || sh[0].Name != "t" || sh[0].Tier != TierSettings {
t.Errorf("settings should be shadowed, got %v", sh)
}
}
// TestSlashTierBuiltinOverridesProject: a built-in added after a project-tier
// template wins; the project entry is shadowed with TierProject.
func TestSlashTierBuiltinOverridesProject(t *testing.T) {
r := NewSlashRegistry()
r.AddProject(SlashCommand{Name: "t", Expand: func(string) string { return "project" }})
r.AddBuiltin(SlashCommand{Name: "t", Action: func(string) string { return "builtin" }})
cmd, ok := r.Lookup("t")
if !ok || cmd.Source != SourceBuiltin {
t.Fatalf("built-in must override project, got ok=%v source=%v", ok, cmd.Source)
}
sh := r.Shadowed()
if len(sh) != 1 || sh[0].Name != "t" || sh[0].Tier != TierProject {
t.Errorf("project should be shadowed, got %v", sh)
}
}
// TestSlashTierSameTierLastWriteWins: two same-tier (global) adds resolve to the
// last one, with no shadow entry recorded.
func TestSlashTierSameTierLastWriteWins(t *testing.T) {
r := NewSlashRegistry()
r.AddUser(SlashCommand{Name: "t", Expand: func(string) string { return "first" }})
r.AddUser(SlashCommand{Name: "t", Expand: func(string) string { return "second" }})
cmd, ok := r.Lookup("t")
if !ok {
t.Fatalf("command %q not found", "t")
}
if got := cmd.Expand(""); got != "second" {
t.Errorf("same-tier last-write-wins, got %q", got)
}
if len(r.Shadowed()) != 0 {
t.Errorf("same-tier override must not shadow, got %v", r.Shadowed())
}
}
// TestSlashTierFullOrdering exercises the full tier ladder: adding lowest-first
// up to built-in, the built-in wins and every lower tier is shadowed.
func TestSlashTierFullOrdering(t *testing.T) {
r := NewSlashRegistry()
r.AddCLI(SlashCommand{Name: "t", Expand: func(string) string { return "cli" }})
r.AddSettings(SlashCommand{Name: "t", Expand: func(string) string { return "settings" }})
r.AddPackage(SlashCommand{Name: "t", Expand: func(string) string { return "package" }})
r.AddUser(SlashCommand{Name: "t", Expand: func(string) string { return "global" }})
r.AddProject(SlashCommand{Name: "t", Expand: func(string) string { return "project" }})
r.AddBuiltin(SlashCommand{Name: "t", Action: func(string) string { return "builtin" }})
cmd, ok := r.Lookup("t")
if !ok || cmd.Source != SourceBuiltin {
t.Fatalf("built-in must win the full ladder, got ok=%v source=%v", ok, cmd.Source)
}
// Five lower-tier commands (cli, settings, package, global, project) lost.
if len(r.Shadowed()) != 5 {
t.Errorf("expected 5 shadowed entries, got %d: %v", len(r.Shadowed()), r.Shadowed())
}
}
// Tests for ParseUserCommand wiring to the expansion engine (US-003, #333):
// Expand tokenizes args via SplitArgs and expands via ExpandTemplate, falling
// back to the raw arg string as $ARGUMENTS when tokenization fails.
// TestParseUserCommandPositionalAndQuoted verifies multi-arg invocation,
// quoted-arg preservation, and the ${1:-default} form through ParseUserCommand.
func TestParseUserCommandPositionalAndQuoted(t *testing.T) {
// /review with no args: $ARGUMENTS expands to empty.
review, _ := ParseUserCommand("review", []byte("Review: $ARGUMENTS"))
if got := review.Expand(""); got != "Review: " {
t.Errorf("no args: got %q, want \"Review: \"", got)
}
// /component Button "click handler": quoted arg stays one token ($2).
comp, _ := ParseUserCommand("component", []byte("name=$1 feat=$2"))
if got := comp.Expand(`Button "click handler"`); got != "name=Button feat=click handler" {
t.Errorf("quoted args: got %q", got)
}
// ${1:-7} default: no arg -> 7, explicit -> the arg.
bul, _ := ParseUserCommand("summarize", []byte("in ${1:-7} bullets"))
if got := bul.Expand(""); got != "in 7 bullets" {
t.Errorf("default no arg: got %q", got)
}
if got := bul.Expand("5"); got != "in 5 bullets" {
t.Errorf("explicit arg: got %q", got)
}
}
// TestParseUserCommandSplitFailureFallback verifies that an unterminated quote
// (SplitArgs error) falls back to treating the raw arg string as $ARGUMENTS.
func TestParseUserCommandSplitFailureFallback(t *testing.T) {
cmd, _ := ParseUserCommand("t", []byte("echo $ARGUMENTS"))
if got := cmd.Expand(`"unterminated`); got != `echo "unterminated` {
t.Errorf("split-failure fallback: got %q, want raw string as $ARGUMENTS", got)
}
// A no-placeholder template with split failure still appends the raw string.
bare, _ := ParseUserCommand("note", []byte("Take a note"))
if got := bare.Expand(`"unterminated`); got != "Take a note\n\n\"unterminated" {
t.Errorf("no-placeholder split failure: got %q", got)
}
}
// TestParseUserCommandArgumentHintAndDescriptionFallback (US-004, #334):
// argument-hint is parsed from frontmatter, and description falls back to the
// first non-empty body line when absent.
func TestParseUserCommandArgumentHintAndDescriptionFallback(t *testing.T) {
// Both description and argument-hint.
cmd, err := ParseUserCommand("pr", []byte("---\ndescription: review PR\nargument-hint: \"<PR-URL>\"\n---\nReview the PR"))
if err != nil {
t.Fatal(err)
}
if cmd.Description != "review PR" {
t.Errorf("description = %q, want \"review PR\"", cmd.Description)
}
if cmd.ArgumentHint != "<PR-URL>" {
t.Errorf("argument-hint = %q, want \"<PR-URL>\"", cmd.ArgumentHint)
}
// Only argument-hint: description falls back to first non-empty body line.
hintOnly, _ := ParseUserCommand("wr", []byte("---\nargument-hint: \"[instructions]\"\n---\nFinish the current task\nend-to-end"))
if hintOnly.Description != "Finish the current task" {
t.Errorf("description fallback = %q, want first body line", hintOnly.Description)
}
if hintOnly.ArgumentHint != "[instructions]" {
t.Errorf("argument-hint = %q, want \"[instructions]\"", hintOnly.ArgumentHint)
}
// Only description: argument-hint stays empty.
descOnly, _ := ParseUserCommand("cl", []byte("---\ndescription: audit changelog\n---\nAudit changelog entries"))
if descOnly.Description != "audit changelog" {
t.Errorf("description = %q", descOnly.Description)
}
if descOnly.ArgumentHint != "" {
t.Errorf("argument-hint should be empty, got %q", descOnly.ArgumentHint)
}
// No frontmatter at all: description falls back to first non-empty line.
neither, _ := ParseUserCommand("bare", []byte("First line is the desc\nSecond line is body"))
if neither.Description != "First line is the desc" {
t.Errorf("no-frontmatter fallback = %q, want first line", neither.Description)
}
if neither.ArgumentHint != "" {
t.Errorf("argument-hint should be empty, got %q", neither.ArgumentHint)
}
}
+183
View File
@@ -0,0 +1,183 @@
// This file implements streamAssistantResponse (US-003): it shapes the context
// into a provider request, resolves the API key dynamically, drives the
// provider stream, and back-fills the partial assistant message into the
// context while emitting message_start / message_update / message_end events.
package runtime
import (
"context"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/provider"
)
// LoopConfig holds the pluggable behavior of the agent loop. Every hook is
// optional (nil = use the default). The pointer/func-field pattern mirrors pi's
// optional callbacks.
type LoopConfig struct {
// Model is the model id passed to StreamFn.
Model string
// APIKey is the static fallback key when GetAPIKey is nil or returns "".
APIKey string
// ThinkingLevel is the reasoning effort for requests.
ThinkingLevel agentcore.ThinkingLevel
// Stream produces the provider stream. Required (defaults are wired by
// callers/tests, e.g. a fake provider).
Stream provider.StreamFn
// TransformContext optionally rewrites the message list before conversion
// (context trimming/injection). Contract: must not error; on failure return
// a safe fallback. Runs first.
TransformContext func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList
// ConvertToLlm optionally filters UI-only messages. Defaults to identity.
// Contract: must not error.
ConvertToLlm func(msgs agentcore.MessageList) agentcore.MessageList
// GetAPIKey optionally resolves a fresh key per request (handles short-lived
// token expiry). Falls back to APIKey when nil or empty.
GetAPIKey func(ctx context.Context, provider string) string
// Provider is the provider name passed to GetAPIKey.
Provider string
// ContextWindow is the model's total context-token budget, used to decide
// automatic compaction. When <= 0 the window is unknown and auto-compaction
// is disabled (ShouldCompact returns false), so the loop behaves exactly as
// before for callers that do not plumb it through.
ContextWindow int
// Compaction holds the thresholds/retention knobs for auto-compaction. Its
// Enabled flag gates the feature independently of ContextWindow.
Compaction compaction.CompactionSettings
// SummaryStream produces the provider stream used to generate compaction
// summaries. Defaults to Stream when nil.
SummaryStream provider.StreamFn
// SummaryModel is the model used for summarization. When zero, a model is
// synthesized from Model/ContextWindow.
SummaryModel provider.Model
// Extra is forwarded to StreamConfig.Extra.
Extra map[string]any
}
// streamAssistantResponse runs one assistant turn: it builds the request from
// agentCtx, streams the provider response, back-fills the partial into
// agentCtx.Messages, and returns the final assistant message. The sequence
// (transformContext → convertToLlm → resolve key → stream → drain) is kept
// identical to pi. It never returns an error for a request failure — such
// failures arrive as a terminal assistant message with stopReason error/aborted.
func streamAssistantResponse(ctx context.Context, agentCtx *agentcore.AgentContext, cfg LoopConfig, emit agentcore.EmitFunc) (agentcore.AssistantMessage, error) {
// 1. transformContext (optional, must not error).
msgs := agentCtx.Messages
if cfg.TransformContext != nil {
msgs = cfg.TransformContext(ctx, msgs)
}
// 2. convertToLlm (filter UI-only; default identity).
if cfg.ConvertToLlm != nil {
msgs = cfg.ConvertToLlm(msgs)
}
// 3. shape the LLM context.
llm := provider.LlmContext{
SystemPrompt: agentCtx.SystemPrompt,
Messages: msgs,
Tools: agentCtx.Tools,
}
// 4. resolve API key dynamically, fall back to static.
key := cfg.APIKey
if cfg.GetAPIKey != nil {
if dyn := cfg.GetAPIKey(ctx, cfg.Provider); dyn != "" {
key = dyn
}
}
// 5. build the provider stream.
stream, err := cfg.Stream(ctx, cfg.Model, llm, provider.StreamConfig{
APIKey: key,
ThinkingLevel: cfg.ThinkingLevel,
Extra: cfg.Extra,
})
if err != nil {
// Early "cannot build stream" failure: synthesize a terminal message so
// the loop has a uniform assistant message to record.
return newErrorAssistantMessage(cfg, err), nil
}
// 6. drain the stream, back-filling the partial into the context.
addedPartial := false
backfill := func(partial agentcore.AssistantMessage) {
if !addedPartial {
agentCtx.Messages = append(agentCtx.Messages, partial)
addedPartial = true
} else {
agentCtx.Messages[len(agentCtx.Messages)-1] = partial
}
}
for ev := range stream.Events() {
switch e := ev.(type) {
case provider.StreamStartEvent:
backfill(e.Partial)
if err := emit(ctx, agentcore.MessageStartEvent{Message: e.Partial}); err != nil {
return agentcore.AssistantMessage{}, err
}
case provider.StreamTextEvent:
backfill(e.Partial)
if err := emit(ctx, agentcore.MessageUpdateEvent{Message: e.Partial, AssistantMessageEvent: e}); err != nil {
return agentcore.AssistantMessage{}, err
}
case provider.StreamThinkingEvent:
backfill(e.Partial)
if err := emit(ctx, agentcore.MessageUpdateEvent{Message: e.Partial, AssistantMessageEvent: e}); err != nil {
return agentcore.AssistantMessage{}, err
}
case provider.StreamToolCallEvent:
backfill(e.Partial)
if err := emit(ctx, agentcore.MessageUpdateEvent{Message: e.Partial, AssistantMessageEvent: e}); err != nil {
return agentcore.AssistantMessage{}, err
}
case provider.StreamDoneEvent:
finalizeMessage(agentCtx, e.Message, &addedPartial)
if err := emit(ctx, agentcore.MessageEndEvent{Message: e.Message}); err != nil {
return agentcore.AssistantMessage{}, err
}
return e.Message, nil
case provider.StreamErrorEvent:
finalizeMessage(agentCtx, e.Message, &addedPartial)
if err := emit(ctx, agentcore.MessageEndEvent{Message: e.Message}); err != nil {
return agentcore.AssistantMessage{}, err
}
return e.Message, nil
}
}
// 7. stream ended without done/error: fall back to the stream result.
final, resErr := stream.Result(ctx)
if resErr != nil {
return newErrorAssistantMessage(cfg, resErr), nil
}
finalizeMessage(agentCtx, final, &addedPartial)
if err := emit(ctx, agentcore.MessageEndEvent{Message: final}); err != nil {
return agentcore.AssistantMessage{}, err
}
return final, nil
}
// finalizeMessage replaces the placeholder partial with the final message, or
// appends it if the provider sent done/error without a prior start.
func finalizeMessage(agentCtx *agentcore.AgentContext, final agentcore.AssistantMessage, addedPartial *bool) {
if *addedPartial {
agentCtx.Messages[len(agentCtx.Messages)-1] = final
} else {
agentCtx.Messages = append(agentCtx.Messages, final)
*addedPartial = true
}
}
// newErrorAssistantMessage builds a terminal assistant message for an early
// failure that never produced a provider stream.
func newErrorAssistantMessage(cfg LoopConfig, err error) agentcore.AssistantMessage {
return agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Model: cfg.Model,
Provider: cfg.Provider,
StopReason: agentcore.StopReasonError,
ErrorMessage: err.Error(),
}
}
@@ -0,0 +1,176 @@
package runtime
import (
"context"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/provider"
)
// fakeStream builds a StreamFn that replays a fixed sequence of events, pushing
// each onto an AssistantMessageEventStream from a producer goroutine.
func fakeStream(events []provider.AssistantMessageEvent) provider.StreamFn {
return func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
s := provider.NewAssistantMessageEventStream(0)
go func() {
for _, ev := range events {
if err := s.Emit(ctx, ev); err != nil {
s.SetError(err)
s.Close()
return
}
}
s.Close()
}()
return s, nil
}
}
// drives streamAssistantResponse with a synchronous emit that records events.
func runStream(t *testing.T, agentCtx *agentcore.AgentContext, cfg LoopConfig) (agentcore.AssistantMessage, []agentcore.AgentEvent) {
t.Helper()
var got []agentcore.AgentEvent
emit := func(ctx context.Context, ev agentcore.AgentEvent) error {
got = append(got, ev)
return nil
}
msg, err := streamAssistantResponse(context.Background(), agentCtx, cfg, emit)
if err != nil {
t.Fatalf("streamAssistantResponse: %v", err)
}
return msg, got
}
func TestStreamResponseBackfillAndEvents(t *testing.T) {
partial0 := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
partial1 := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("hel")}}
final := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("hello")}, StopReason: agentcore.StopReasonEndTurn}
cfg := LoopConfig{
Model: "fake",
Stream: fakeStream([]provider.AssistantMessageEvent{
provider.StreamStartEvent{Partial: partial0},
provider.StreamTextEvent{Partial: partial1},
provider.StreamDoneEvent{Message: final},
}),
}
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
msg, events := runStream(t, agentCtx, cfg)
if msg.StopReason != agentcore.StopReasonEndTurn {
t.Errorf("final stopReason = %q, want end_turn", msg.StopReason)
}
// Context should hold the user message + the final assistant message (the
// placeholder was replaced, not appended twice).
if len(agentCtx.Messages) != 2 {
t.Fatalf("context messages = %d, want 2: %+v", len(agentCtx.Messages), agentCtx.Messages)
}
last, ok := agentCtx.Messages[1].(agentcore.AssistantMessage)
if !ok || len(last.Content) != 1 {
t.Fatalf("last message not final assistant: %+v", agentCtx.Messages[1])
}
// Event order: message_start, message_update, message_end.
wantKinds := []string{agentcore.EventMessageStart, agentcore.EventMessageUpdate, agentcore.EventMessageEnd}
if len(events) != len(wantKinds) {
t.Fatalf("event count = %d, want %d: %+v", len(events), len(wantKinds), events)
}
for i, w := range wantKinds {
if events[i].EventType() != w {
t.Errorf("event[%d] = %q, want %q", i, events[i].EventType(), w)
}
}
}
func TestStreamResponseErrorEvent(t *testing.T) {
errMsg := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonError, ErrorMessage: "boom"}
cfg := LoopConfig{
Model: "fake",
Stream: fakeStream([]provider.AssistantMessageEvent{provider.StreamErrorEvent{Message: errMsg}}),
}
agentCtx := &agentcore.AgentContext{}
msg, events := runStream(t, agentCtx, cfg)
if msg.StopReason != agentcore.StopReasonError || msg.ErrorMessage != "boom" {
t.Errorf("want error terminal message, got %+v", msg)
}
// No start event was sent; error should still append the terminal message.
if len(agentCtx.Messages) != 1 {
t.Fatalf("context messages = %d, want 1", len(agentCtx.Messages))
}
if events[len(events)-1].EventType() != agentcore.EventMessageEnd {
t.Errorf("last event = %q, want message_end", events[len(events)-1].EventType())
}
}
func TestStreamResponseDynamicAPIKey(t *testing.T) {
var seenKey string
streamFn := func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
seenKey = cfg.APIKey
s := provider.NewAssistantMessageEventStream(0)
go func() {
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn}})
s.Close()
}()
return s, nil
}
cfg := LoopConfig{
Model: "fake",
APIKey: "static-key",
Provider: "test",
Stream: streamFn,
GetAPIKey: func(ctx context.Context, provider string) string { return "dynamic-key" },
}
runStream(t, &agentcore.AgentContext{}, cfg)
if seenKey != "dynamic-key" {
t.Errorf("dynamic key not used: got %q", seenKey)
}
// Empty dynamic key falls back to static.
cfg.GetAPIKey = func(ctx context.Context, provider string) string { return "" }
runStream(t, &agentcore.AgentContext{}, cfg)
if seenKey != "static-key" {
t.Errorf("fallback to static key failed: got %q", seenKey)
}
}
func TestStreamResponseTransformAndConvertOrder(t *testing.T) {
var order []string
cfg := LoopConfig{
Model: "fake",
TransformContext: func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList {
order = append(order, "transform")
return msgs
},
ConvertToLlm: func(msgs agentcore.MessageList) agentcore.MessageList {
order = append(order, "convert")
return msgs
},
Stream: func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
order = append(order, "stream")
s := provider.NewAssistantMessageEventStream(0)
go func() {
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}})
s.Close()
}()
return s, nil
},
}
runStream(t, &agentcore.AgentContext{}, cfg)
if len(order) != 3 || order[0] != "transform" || order[1] != "convert" || order[2] != "stream" {
t.Errorf("call order wrong: %v", order)
}
}
func TestStreamResponseEarlyBuildFailure(t *testing.T) {
cfg := LoopConfig{
Model: "fake",
Stream: func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
return nil, context.DeadlineExceeded
},
}
msg, _ := runStream(t, &agentcore.AgentContext{}, cfg)
if msg.StopReason != agentcore.StopReasonError {
t.Errorf("early build failure should yield error message, got %+v", msg)
}
}
+472
View File
@@ -0,0 +1,472 @@
// This file implements sub-agent orchestration (US-027, #45) with an optional
// process-isolation mode (US-019, #135).
//
// A sub-agent is a full agent loop with its own AgentContext (independent system
// prompt, message history and tool set), launched by the parent through a normal
// tool call. The child runs to completion and its final assistant text is fed
// back to the parent as the tool result - so from the parent loop's perspective
// a sub-agent is just another tool.
//
// Two isolation modes are supported, selected by SubAgentSpec.Isolation:
//
// - Goroutine (default): the child loop runs in-process in a goroutine sharing
// the parent process, matching the original "single-process goroutine" decision.
// - Process: the parent spawns a fresh pigo subprocess (pigo --subagent-rpc)
// and delegates the run over stdio JSON-RPC (reusing internal/jsonrpc). The
// child runs in a separate process, so a crash or resource leak in the child
// cannot affect the parent loop; a crash is surfaced as a tool error. The
// subprocess resolves its own provider from the model/provider passed in the
// request and inherits the parent environment for credentials.
//
// Because each Execute call spins up an independent run, multiple sub-agents can
// run concurrently (the batch executor already runs parallel tool calls in
// separate goroutines/processes).
package runtime
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/jsonrpc"
)
// SubAgentIsolation selects how a sub-agent runs relative to its parent.
type SubAgentIsolation int
const (
// SubAgentIsolationGoroutine runs the child agent loop in-process in a
// goroutine. This is the default and the original behavior; it changes
// nothing about how sub-agents previously ran.
SubAgentIsolationGoroutine SubAgentIsolation = iota
// SubAgentIsolationProcess runs the child in a fresh pigo subprocess,
// delegating the run over stdio JSON-RPC. A subprocess crash is surfaced to
// the parent as a tool error and never affects the parent loop.
SubAgentIsolationProcess
)
// SubAgentProcessConfig configures process-isolated sub-agent execution. It
// carries the serializable provider config the subprocess needs to reconstruct
// the run: the in-process Stream/GetAPIKey functions a goroutine-mode
// NewRunConfig returns cannot cross a process boundary, so the parent forwards
// the model (and optional base URL/protocol) and the subprocess resolves the
// provider itself, inheriting the parent environment for API keys.
type SubAgentProcessConfig struct {
// Command is the executable to spawn. When empty, os.Executable() (the pigo
// binary itself) is used so a pigo process spawns another pigo.
Command string
// Args are appended to the command after the subagent-rpc flag. Rarely
// needed; reserved for test doubles or non-standard layouts.
Args []string
// Model is the model id the subprocess runs against. Required. A preset id
// (e.g. "openrouter/free", "anthropic/claude-...") or ollama/nvidia-prefixed
// id resolves its own provider; a custom gateway needs BaseURL/Protocol.
Model string
// BaseURL and Protocol override the provider endpoint and wire protocol for
// custom gateways (Protocol "anthropic"/"openai" forces that wire format).
// Empty falls back to the same resolution the CLI uses.
BaseURL string
Protocol string
// ToolNames restricts the subprocess's builtin tool set to the named tools
// (e.g. a read-only researcher). Empty keeps all builtins. Non-builtin names
// are ignored: custom/plugin tools cannot cross a process boundary, so a
// process-isolated child runs with builtins only.
ToolNames []string
// Env is the child's environment (os/exec form). When nil the child inherits
// the parent environment, which is how it picks up provider API keys.
Env []string
// Dir is the child's working directory; empty means the parent's.
Dir string
// Stderr optionally receives the child's stderr. When nil it is discarded.
Stderr io.Writer
}
// SubAgentRunParams is the JSON-RPC request payload for a process-isolated
// sub-agent run (the "subagent/run" method). It is the wire contract between
// the parent (SubAgentTool in process mode) and the pigo subprocess
// (cmd/pigo --subagent-rpc).
type SubAgentRunParams struct {
Prompt string `json:"prompt"`
SystemPrompt string `json:"systemPrompt,omitempty"`
Model string `json:"model"`
BaseURL string `json:"baseUrl,omitempty"`
Protocol string `json:"protocol,omitempty"`
Tools []string `json:"tools,omitempty"`
}
// SubAgentRunResult is the JSON-RPC response payload carrying the child's final
// assistant text.
type SubAgentRunResult struct {
Text string `json:"text"`
}
// SubAgentRPCMethod is the JSON-RPC method name the parent calls on the
// subprocess: "subagent/run".
const SubAgentRPCMethod = "subagent/run"
// SubAgentRPCFlag is the command-line flag the parent launches the pigo
// subprocess with so it enters the sub-agent RPC server mode: "--subagent-rpc".
const SubAgentRPCFlag = "--subagent-rpc"
// SubAgentSpec declares a spawnable sub-agent: its identity (surfaced to the
// model as a tool), the system prompt and tools its child context runs with,
// and a factory for the child's run configuration (provider stream, batch
// registry, hooks). The factory is called once per spawn so each child gets an
// independent RunConfig; NewRunConfig must wire a ToolRegistry consistent with
// Tools. It is used by goroutine mode; process mode uses Process instead (the
// subprocess builds its own RunConfig from the serializable provider config).
type SubAgentSpec struct {
// Name is the tool name the parent invokes to spawn this sub-agent.
Name string
// Description is injected into the parent's tool list / capability list so
// the model knows when to delegate.
Description string
// SystemPrompt seeds the child context's system prompt. When empty the child
// runs with no system prompt.
SystemPrompt string
// Tools is the child's independent tool set. It may differ from the parent's
// (e.g. a read-only researcher sub-agent) and may be empty. In goroutine
// mode these exact tools run in-process; in process mode only the tools'
// NAMES are forwarded (the subprocess rebuilds builtins by name).
Tools []agentcore.AgentTool
// NewRunConfig builds the loop configuration for one child run in goroutine
// mode. It is called per spawn; the returned config's Batch registry should
// contain Tools. Ignored in process mode (the subprocess resolves its own).
NewRunConfig func() RunConfig
// Isolation selects goroutine (default) vs process execution. Zero value is
// goroutine, preserving the original behavior.
Isolation SubAgentIsolation
// Process configures process-isolated execution. Required when Isolation is
// SubAgentIsolationProcess; ignored otherwise.
Process SubAgentProcessConfig
// Schema, when non-empty, overrides the default single-prompt argument schema
// advertised to the model. The generic task tool uses this to also accept an
// optional description; a nil/empty Schema keeps the original prompt-only
// schema so existing specs are unaffected.
Schema json.RawMessage
// Sem, when non-nil, is a shared buffered channel used as a concurrency
// semaphore for goroutine-mode runs: executeGoroutine acquires a slot before
// spawning the child and releases it when the child settles. A full channel
// blocks (queues) the acquire rather than erroring. nil disables limiting, so
// existing sub-agent specs run unbounded exactly as before.
Sem chan struct{}
}
// subAgentArgs is the JSON argument shape for a sub-agent tool call: a
// free-form prompt describing the delegated task, plus an optional short
// description used for status display (accepted by the generic task tool;
// ignored by prompt-only specs).
type subAgentArgs struct {
Prompt string `json:"prompt"`
Description string `json:"description,omitempty"`
}
// subAgentSchema is the JSON Schema validating a sub-agent invocation.
var subAgentSchema = json.RawMessage(`{
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The task for the sub-agent to perform, described in full since the sub-agent runs with a fresh context."
}
},
"required": ["prompt"],
"additionalProperties": false
}`)
// SubAgentTool adapts a SubAgentSpec into an AgentTool. Executing it spawns a
// child agent run (in a goroutine or a subprocess, per Isolation) and returns
// the child's final text.
type SubAgentTool struct {
spec SubAgentSpec
// processCall, when non-nil, overrides the default subprocess transport for
// process-isolated mode. Tests inject a fake to exercise the process-mode
// logic (params shaping, crash-as-error, result forwarding) without building
// a real binary; production leaves it nil so Execute uses defaultProcessCall.
processCall func(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error)
}
// NewSubAgentTool builds a sub-agent tool from a spec. In goroutine mode
// NewRunConfig is required (it supplies the provider stream that drives the
// child); in process mode Process.Model is required instead.
func NewSubAgentTool(spec SubAgentSpec) *SubAgentTool {
return &SubAgentTool{spec: spec}
}
func (t *SubAgentTool) Name() string { return t.spec.Name }
func (t *SubAgentTool) Description() string { return t.spec.Description }
func (t *SubAgentTool) Schema() json.RawMessage {
if len(t.spec.Schema) > 0 {
return t.spec.Schema
}
return subAgentSchema
}
// ExecutionMode is parallel: independent sub-agents may run concurrently, since
// each spawns its own context and run (goroutine or process) with no shared
// mutable state.
func (t *SubAgentTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionParallel
}
// Execute spawns the child agent run and blocks until it settles, then returns
// the child's final assistant text as the tool result. The parent's ctx governs
// the child, so cancelling the parent run cancels in-flight sub-agents (in
// goroutine mode via ctx; in process mode via ctx cancelling the JSON-RPC call
// and Close killing the child).
func (t *SubAgentTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
// Goroutine mode requires NewRunConfig (it supplies the in-process provider
// stream). Process mode does not - the subprocess resolves its own provider
// from Process.Model - so the check is guarded to goroutine mode. This
// preserves the original precedence (nil NewRunConfig reported before an
// empty prompt) for the unchanged goroutine path.
if t.spec.Isolation != SubAgentIsolationProcess && t.spec.NewRunConfig == nil {
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: no run configuration", t.spec.Name)
}
var a subAgentArgs
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: decode args: %w", t.spec.Name, err)
}
}
if a.Prompt == "" {
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: empty prompt", t.spec.Name)
}
if t.spec.Isolation == SubAgentIsolationProcess {
// Process mode returns only the child's final text (the JSON-RPC protocol
// does not stream partial updates), so onUpdate is intentionally not
// forwarded here; a caller supplying a sink gets no deltas in this mode.
return t.executeProcess(ctx, a.Prompt)
}
return t.executeGoroutine(ctx, id, a.Prompt, a.Description, onUpdate)
}
// executeGoroutine runs the child agent loop in-process and returns its final
// text. This is the default mode and the original sub-agent behavior.
//
// id is the parent tool call's id and description is the (optional) task
// description; both are threaded onto any SubAgentProgressEvent emitted for this
// run so a consumer can key status by the parent task call. When the parent loop
// injected a run-level progress emitter into ctx (WithProgressEmitter), the
// child's tool-execution / turn boundaries are translated into
// SubAgentProgressEvent and surfaced up the parent stream; when no emitter is
// present (e.g. the tool is called directly in a unit test) progress reporting is
// silently skipped.
func (t *SubAgentTool) executeGoroutine(ctx context.Context, id, prompt, description string, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
// Concurrency guard: when a shared semaphore is configured, acquire a slot
// before spawning the child and release it via defer so a panic or error
// still frees the slot. A full channel blocks (queues) the acquire; a
// cancelled parent ctx abandons the wait instead of blocking forever.
if t.spec.Sem != nil {
select {
case t.spec.Sem <- struct{}{}:
defer func() { <-t.spec.Sem }()
case <-ctx.Done():
return agentcore.AgentToolResult{}, ctx.Err()
}
}
runCfg := t.spec.NewRunConfig()
// Advertise the child's tools to the model. A spec may pin an explicit set
// (spec.Tools); otherwise fall back to the run config's registry — the tools
// the executor can actually run — so a factory that wires only the registry
// (like the generic task tool) still tells the child what it can call.
// Without this the model is handed an empty tool list, can only reply with
// text, and a delegated task that needs tools comes back empty.
tools := t.spec.Tools
if len(tools) == 0 && runCfg.Batch.ToolExecutorConfig.Registry != nil {
tools = runCfg.Batch.ToolExecutorConfig.Registry.List()
}
childCtx := &agentcore.AgentContext{
SystemPrompt: t.spec.SystemPrompt,
Messages: agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(prompt)}},
},
Tools: tools,
}
stream := StartRun(ctx, childCtx, runCfg)
// Drain events (DrainStream never returns early, so the producer goroutine is
// never blocked on back-pressure); forward streamed child text as
// tool-execution updates when a sink is set.
var h StreamHandler
if onUpdate != nil {
h.OnText = func(delta string) {
onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(delta)}})
}
}
// Progress reporting: when the parent loop injected a run-level emitter into
// ctx, translate the child's tool-execution / turn boundaries into
// SubAgentProgressEvent and emit them up the parent stream. Reporting is at
// activity granularity (per child tool start / turn boundary), NOT per text
// delta, so event volume stays proportional to the child's tool calls. When
// no emitter is present the OnEvent hook is left nil and progress is skipped.
if parentEmit := agentcore.ProgressEmitterFromContext(ctx); parentEmit != nil {
// chars accumulates the child's streamed text length so a coarse output
// token estimate can ride along on each progress event (0 = unknown).
chars := 0
if prev := h.OnText; prev != nil {
h.OnText = func(delta string) {
chars += len(delta)
prev(delta)
}
} else {
h.OnText = func(delta string) { chars += len(delta) }
}
h.OnEvent = func(ev agentcore.AgentEvent) {
act := activityOf(ev)
if act == "" {
return
}
_ = parentEmit(ctx, agentcore.SubAgentProgressEvent{
ToolCallID: id,
Description: description,
Activity: act,
Tokens: estimateTokens(chars),
})
}
}
final, err := DrainStream(ctx, stream, h)
if err != nil {
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: %w", t.spec.Name, err)
}
text := ""
if final != nil {
text = agentcore.ContentToText(final.Content)
}
if text == "" {
text = fmt.Sprintf("(sub-agent %q produced no text output)", t.spec.Name)
}
// Surface a failed child run as a tool error so the parent model gets a
// signal the delegation failed (the tool executor marks the result
// IsError). A child whose final turn stopped on error/aborted otherwise
// looks like a successful delegation carrying error text.
if final != nil && (final.StopReason == agentcore.StopReasonError || final.StopReason == agentcore.StopReasonAborted) {
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q failed (%s): %s", t.spec.Name, final.StopReason, text)
}
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
}
// executeProcess runs the child agent loop in a fresh pigo subprocess over stdio
// JSON-RPC and returns its final text. A subprocess crash, transport error, or
// failed child run is surfaced as a tool error; the parent loop is unaffected.
// Streamed child text is not forwarded (the process protocol returns only the
// final result); the parent sees the complete result when the child settles.
func (t *SubAgentTool) executeProcess(ctx context.Context, prompt string) (agentcore.AgentToolResult, error) {
cfg := t.spec.Process
if cfg.Model == "" {
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: process mode requires Process.Model", t.spec.Name)
}
// Forward the child's tool names so the subprocess can rebuild a matching
// builtin set; an explicit ToolNames list wins over deriving from Tools.
toolNames := cfg.ToolNames
if len(toolNames) == 0 {
for _, tl := range t.spec.Tools {
toolNames = append(toolNames, tl.Name())
}
}
params := SubAgentRunParams{
Prompt: prompt,
SystemPrompt: t.spec.SystemPrompt,
Model: cfg.Model,
BaseURL: cfg.BaseURL,
Protocol: cfg.Protocol,
Tools: toolNames,
}
call := t.processCall
if call == nil {
call = defaultProcessCall
}
text, err := call(ctx, cfg, params)
if err != nil {
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q (process): %w", t.spec.Name, err)
}
if text == "" {
text = fmt.Sprintf("(sub-agent %q produced no text output)", t.spec.Name)
}
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
}
// defaultProcessCall is the production subprocess transport: it launches the
// pigo binary (or cfg.Command) with the subagent-rpc flag, sends a single
// "subagent/run" JSON-RPC request over the child's stdin, and returns the
// child's final text from the response. The child is closed (killed if it does
// not exit on its own) before returning. A crash, transport error, or RPC error
// is returned as a Go error so executeProcess surfaces it as a tool error.
func defaultProcessCall(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
command := cfg.Command
if command == "" {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve pigo executable: %w", err)
}
command = exe
}
args := append([]string{SubAgentRPCFlag}, cfg.Args...)
client, err := jsonrpc.NewClient(jsonrpc.Config{
Command: command,
Args: args,
Env: cfg.Env,
Dir: cfg.Dir,
Stderr: cfg.Stderr,
})
if err != nil {
return "", err
}
defer client.Close()
raw, err := client.Call(ctx, SubAgentRPCMethod, params)
if err != nil {
return "", err
}
var res SubAgentRunResult
if err := json.Unmarshal(raw, &res); err != nil {
return "", fmt.Errorf("decode sub-agent result: %w", err)
}
return res.Text, nil
}
// RunSubAgentOnce runs one sub-agent loop to completion and returns the child's
// final assistant text. It is the execution core shared by the process-isolated
// subprocess (cmd/pigo --subagent-rpc): given a resolved RunConfig (provider
// stream, tool registry) and the prompt/system prompt, it builds a fresh child
// context and drains the run. A run whose final turn stopped on error/aborted
// is reported as an error so the subprocess surfaces failure (as an RPC error)
// rather than returning empty text. It does not stream partial updates: the
// process protocol returns only the final result.
func RunSubAgentOnce(ctx context.Context, systemPrompt, prompt string, tools []agentcore.AgentTool, runCfg RunConfig) (string, error) {
childCtx := &agentcore.AgentContext{
SystemPrompt: systemPrompt,
Messages: agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(prompt)}},
},
Tools: tools,
}
stream := StartRun(ctx, childCtx, runCfg)
final, err := DrainStream(ctx, stream, StreamHandler{})
if err != nil {
return "", err
}
text := ""
if final != nil {
text = agentcore.ContentToText(final.Content)
}
if final != nil && (final.StopReason == agentcore.StopReasonError || final.StopReason == agentcore.StopReasonAborted) {
// When the loop synthesizes an error turn (e.g. a provider connection
// failure) the diagnostic lands in ErrorMessage, not Content; fall back
// to it so the subprocess surfaces the real cause rather than a bare
// "error" stop reason.
if text == "" && final.ErrorMessage != "" {
text = final.ErrorMessage
}
if text == "" {
text = string(final.StopReason)
}
return text, fmt.Errorf("sub-agent failed (%s): %s", final.StopReason, text)
}
return text, nil
}
@@ -0,0 +1,328 @@
package runtime
// Tests for process-isolated sub-agents (US-019, #135): the Isolation field,
// the process-mode Execute path (params shaping, crash-as-tool-error), the
// shared RunSubAgentOnce core, and the real defaultProcessCall transport
// (exec + stdio JSON-RPC + crash handling) driven through a tiny compiled
// helper binary - no provider or network, mirroring the plugin tests.
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
goruntime "runtime"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/provider"
)
// errorTurn scripts a turn whose final message stops on StopReasonError, so a
// RunSubAgentOnce run surfaces as a failure (matching the goroutine-mode
// "failed run -> tool error" contract).
func errorTurn(msg string) fauxTurn {
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
final := partial
final.StopReason = agentcore.StopReasonError
final.ErrorMessage = msg
return fauxTurn{
provider.StreamStartEvent{Partial: partial},
provider.StreamDoneEvent{Message: final},
}
}
// TestSubAgentGoroutineModeUnchanged verifies the default (zero-value) isolation
// still runs in-process: the existing parent->child->parent tests cover the
// full path, but this pins that Isolation==goroutine is the default and reaches
// NewRunConfig (not the process path) so the regression is caught locally too.
func TestSubAgentGoroutineModeUnchanged(t *testing.T) {
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{textTurn("child answer")},
}
sub := NewSubAgentTool(SubAgentSpec{
Name: "researcher",
SystemPrompt: "you are a researcher",
NewRunConfig: func() RunConfig { return newFauxRunCfg(child) },
})
if sub.spec.Isolation != SubAgentIsolationGoroutine {
t.Errorf("default Isolation = %v, want goroutine", sub.spec.Isolation)
}
res, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
if err != nil {
t.Fatalf("goroutine Execute err = %v", err)
}
if got := agentcore.ContentToText(res.Content); got != "child answer" {
t.Errorf("goroutine result = %q, want 'child answer'", got)
}
if child.callCount() != 1 {
t.Errorf("child provider calls = %d, want 1", child.callCount())
}
}
// TestSubAgentGoroutineNilRunConfigErrors verifies goroutine mode reports a
// missing NewRunConfig, and - preserving the original precedence - reports it
// even when the prompt is empty (so a nil config is not masked by "empty
// prompt"). This pins the L3 regression: the NewRunConfig check stays before
// the empty-prompt check on the goroutine path.
func TestSubAgentGoroutineNilRunConfigErrors(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{Name: "x"}) // no NewRunConfig, default goroutine
_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
if err == nil || !strings.Contains(err.Error(), "no run configuration") {
t.Errorf("err = %v, want 'no run configuration'", err)
}
// Precedence: nil NewRunConfig is reported even when the prompt is empty.
_, err = sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":""}`), nil)
if err == nil || !strings.Contains(err.Error(), "no run configuration") {
t.Errorf("empty-prompt err = %v, want 'no run configuration' (nil config takes precedence)", err)
}
}
// TestSubAgentProcessModeFake drives process mode through an injectable
// processCall (the test seam) to verify params shaping and crash-as-tool-error
// without a real subprocess. The real transport is covered separately by
// TestSubAgentProcessDefaultCall.
func TestSubAgentProcessModeFake(t *testing.T) {
t.Run("happy", func(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
Process: SubAgentProcessConfig{Model: "faux"},
SystemPrompt: "you are a subprocess child",
})
var got SubAgentRunParams
sub.processCall = func(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
got = params
return "process result: 99", nil
}
res, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"find it"}`), nil)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if got := agentcore.ContentToText(res.Content); got != "process result: 99" {
t.Errorf("result = %q, want 'process result: 99'", got)
}
// The prompt, system prompt, and model are forwarded to the subprocess;
// NewRunConfig is NOT called (the subprocess resolves its own provider).
if got.Prompt != "find it" || got.Model != "faux" || got.SystemPrompt != "you are a subprocess child" {
t.Errorf("forwarded params = %+v", got)
}
})
t.Run("crash is tool error", func(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
Process: SubAgentProcessConfig{Model: "faux"},
})
sub.processCall = func(context.Context, SubAgentProcessConfig, SubAgentRunParams) (string, error) {
return "", errors.New("subprocess exited: signal: killed")
}
_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil)
if err == nil {
t.Fatal("expected tool error on subprocess crash, got nil")
}
})
t.Run("missing model errors", func(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
// Process.Model intentionally empty.
})
_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil)
if err == nil {
t.Fatal("expected error when Process.Model is missing")
}
})
t.Run("forwards tool names", func(t *testing.T) {
// spec.Tools is in-process; process mode forwards only their names so
// the subprocess can rebuild builtins by name.
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
Process: SubAgentProcessConfig{Model: "faux"},
Tools: []agentcore.AgentTool{nameOnlyTool("read"), nameOnlyTool("grep")},
})
var got SubAgentRunParams
sub.processCall = func(_ context.Context, _ SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
got = params
return "ok", nil
}
if _, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil); err != nil {
t.Fatalf("Execute err = %v", err)
}
if len(got.Tools) != 2 || got.Tools[0] != "read" || got.Tools[1] != "grep" {
t.Errorf("forwarded tool names = %v, want [read grep]", got.Tools)
}
})
}
// nameOnlyTool is a minimal AgentTool whose only meaningful attribute is its
// Name, used to verify process mode forwards tool names without needing real
// tool implementations.
func nameOnlyTool(name string) agentcore.AgentTool { return nameOnly{name: name} }
type nameOnly struct{ name string }
func (t nameOnly) Name() string { return t.name }
func (t nameOnly) Description() string { return "" }
func (t nameOnly) Schema() json.RawMessage { return json.RawMessage(`{}`) }
func (t nameOnly) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionParallel
}
func (t nameOnly) Execute(context.Context, string, json.RawMessage, agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return agentcore.AgentToolResult{}, nil
}
// TestRunSubAgentOnce verifies the shared subprocess-side agent core: a normal
// run returns the child's final text, and a run whose final turn stopped on
// error is reported as an error (so the subprocess surfaces it as an RPC error
// and the parent marks the tool result IsError).
func TestRunSubAgentOnce(t *testing.T) {
t.Run("success", func(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{textTurn("hello from child")},
}
text, err := RunSubAgentOnce(context.Background(), "sys", "do it", nil, newFauxRunCfg(p))
if err != nil {
t.Fatalf("err = %v", err)
}
if text != "hello from child" {
t.Errorf("text = %q, want 'hello from child'", text)
}
if p.callCount() != 1 {
t.Errorf("provider calls = %d, want 1", p.callCount())
}
})
t.Run("failed run errors", func(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{errorTurn("boom")},
}
_, err := RunSubAgentOnce(context.Background(), "sys", "do it", nil, newFauxRunCfg(p))
if err == nil {
t.Fatal("expected error for failed child run, got nil")
}
// The diagnostic is in ErrorMessage (errorTurn sets no Content); the
// subprocess must surface it rather than a bare "error" stop reason.
if !strings.Contains(err.Error(), "boom") {
t.Errorf("error %q does not contain the 'boom' diagnostic", err.Error())
}
})
}
// TestSubAgentProcessDefaultCall exercises the real defaultProcessCall transport
// (exec + stdio JSON-RPC) against a tiny compiled helper binary. It verifies the
// happy round-trip (prompt forwarded, result decoded) and that a crashing
// subprocess is surfaced as a Go error (the AC: "a subprocess crash is caught by the parent as a tool error").
func TestSubAgentProcessDefaultCall(t *testing.T) {
bin := buildSubAgentHelper(t)
cfg := SubAgentProcessConfig{Command: bin}
t.Run("happy round-trip", func(t *testing.T) {
text, err := defaultProcessCall(context.Background(), cfg, SubAgentRunParams{
Prompt: "hello", Model: "faux", SystemPrompt: "sys",
})
if err != nil {
t.Fatalf("defaultProcessCall err = %v", err)
}
if text != "echo: hello" {
t.Errorf("text = %q, want 'echo: hello'", text)
}
})
t.Run("crash is error", func(t *testing.T) {
_, err := defaultProcessCall(context.Background(), cfg, SubAgentRunParams{
Prompt: "CRASH", Model: "faux",
})
if err == nil {
t.Fatal("expected error for crashing subprocess, got nil")
}
})
}
// buildSubAgentHelper compiles the sub-agent helper binary into a temp dir and
// returns its path. The helper speaks the same JSON-RPC "subagent/run" wire
// format as pigo --subagent-rpc (decoded with plain encoding/json, no internal
// imports, so it stays a standalone main package).
func buildSubAgentHelper(t *testing.T) string {
t.Helper()
dir := t.TempDir()
srcPath := filepath.Join(dir, "subagent_helper.go")
if err := os.WriteFile(srcPath, []byte(subAgentHelperSrc), 0o644); err != nil {
t.Fatalf("write helper source: %v", err)
}
bin := filepath.Join(dir, "subagent_helper")
if goruntime.GOOS == "windows" {
bin += ".exe"
}
cmd := exec.Command("go", "build", "-o", bin, srcPath)
cmd.Env = os.Environ()
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build sub-agent helper: %v\n%s", err, out)
}
return bin
}
// subAgentHelperSrc is a tiny JSON-RPC server mirroring pigo --subagent-rpc:
// read a "subagent/run" request per line, and either respond with
// {text:"echo: <prompt>"} or, when the prompt is "CRASH", exit without
// responding to simulate a subprocess crash.
const subAgentHelperSrc = `package main
import (
"bufio"
"encoding/json"
"os"
)
type params struct {
Prompt string ` + "`json:\"prompt\"`" + `
}
type request struct {
ID *json.RawMessage ` + "`json:\"id\"`" + `
Method string ` + "`json:\"method\"`" + `
Params params ` + "`json:\"params\"`" + `
}
func main() {
sc := bufio.NewScanner(os.Stdin)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
enc := json.NewEncoder(os.Stdout)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var r request
if err := json.Unmarshal(line, &r); err != nil {
continue
}
if r.Params.Prompt == "CRASH" {
// Simulate a subprocess crash: exit without writing a response so
// the parent's JSON-RPC reader sees EOF and fails the call.
os.Exit(1)
}
result, _ := json.Marshal(map[string]string{"text": "echo: " + r.Params.Prompt})
resp := map[string]any{
"jsonrpc": "2.0",
"id": r.ID,
"result": json.RawMessage(result),
}
_ = enc.Encode(resp)
}
}
`
+141
View File
@@ -0,0 +1,141 @@
// This file implements the generic `task` tool (US-002, #454): a general-purpose
// sub-agent the model can dispatch with a free-form prompt to fan out work in a
// single assistant message. It is a thin specialization of SubAgentTool - it
// reuses executeGoroutine's child-loop driving - configured with a generic
// system prompt (the delegated task comes from the call arguments at runtime),
// a shared concurrency semaphore, and a child tool set from which `task` itself
// is excluded (the nesting guard, wired in internal/cli/run).
package runtime
import (
"encoding/json"
"os"
"strconv"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// DefaultMaxSubagents is the concurrency cap applied when PIGO_MAX_SUBAGENTS is
// unset or invalid. It bounds how many task sub-agents run at once so a fan-out
// cannot overwhelm the provider rate limit.
const DefaultMaxSubagents = 4
// taskDescription is advertised to the parent model so it knows when to delegate
// work to a generic sub-agent.
const taskDescription = "Dispatch a general-purpose sub-agent to autonomously complete a delegated task. " +
"The sub-agent runs its own agent loop with a fresh context and the standard tool set, then returns its final report. " +
"Provide a complete, self-contained prompt since the sub-agent shares none of this conversation's context. " +
"Multiple task calls in one message run in parallel."
// taskSystemPrompt seeds every generic sub-agent's context. It is intentionally
// generic (the actual work arrives as the runtime prompt) and mirrors the
// parent agent's operating posture so a delegated task is carried out the same
// way the parent would.
const taskSystemPrompt = "You are a focused sub-agent working on one delegated task. " +
"You have your own fresh context and the standard tool set, but you cannot spawn further sub-agents. " +
"Complete the task fully using the tools available, then respond with a concise final report of what you did and any key findings. " +
"Your final message is returned verbatim to the agent that dispatched you, so make it self-contained."
// taskSchema is the JSON Schema for a task invocation: a required self-contained
// prompt plus an optional short description used for status display.
var taskSchema = json.RawMessage(`{
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the task, for status display."
},
"prompt": {
"type": "string",
"description": "The full task for the sub-agent to perform. It must be self-contained since the sub-agent runs with a fresh context and shares none of this conversation."
}
},
"required": ["prompt"],
"additionalProperties": false
}`)
// NewTaskTool builds the generic `task` sub-agent tool. factory produces a fresh
// child RunConfig per spawn (reusing the parent's provider stream/model and a
// child tool registry that must exclude `task` for the nesting guard); sem is a
// shared buffered channel bounding concurrent task runs (nil disables limiting).
// The child prompt comes from the call arguments at runtime, so a single generic
// spec serves every delegated task.
func NewTaskTool(factory func() RunConfig, sem chan struct{}) *SubAgentTool {
return NewSubAgentTool(SubAgentSpec{
Name: "task",
Description: taskDescription,
SystemPrompt: taskSystemPrompt,
Schema: taskSchema,
NewRunConfig: factory,
Sem: sem,
})
}
// MaxSubagents resolves the concurrency cap for task sub-agents from
// PIGO_MAX_SUBAGENTS: absent or unparseable yields DefaultMaxSubagents (4), and
// a parsed value below 1 is floored to 1 so the semaphore always admits at least
// one runner.
func MaxSubagents() int {
v := strings.TrimSpace(os.Getenv("PIGO_MAX_SUBAGENTS"))
if v == "" {
return DefaultMaxSubagents
}
n, err := strconv.Atoi(v)
if err != nil {
return DefaultMaxSubagents
}
if n < 1 {
return 1
}
return n
}
// NewSubagentSemaphore builds the shared concurrency semaphore for task
// sub-agents, sized by MaxSubagents. One instance per run is created and shared
// across every task call so the cap is enforced run-wide.
func NewSubagentSemaphore() chan struct{} {
return make(chan struct{}, MaxSubagents())
}
// activityOf maps a child sub-agent event to the display verb surfaced in a
// SubAgentProgressEvent (D-8: tool name / phase, no argument summary). A child
// ToolExecutionStartEvent maps by tool name; a TurnStartEvent (a fresh turn with
// no tool in progress) maps to "Thinking". Every other event maps to "" so the
// caller emits nothing — progress is reported only at these activity boundaries,
// keeping event volume proportional to the child's tool calls rather than its
// text deltas (D-7).
func activityOf(ev agentcore.AgentEvent) string {
switch e := ev.(type) {
case agentcore.ToolExecutionStartEvent:
switch e.ToolName {
case "read":
return "Reading"
case "edit", "write":
return "Editing"
case "bash":
return "Running bash"
case "grep", "find", "ls":
return "Searching"
case "webfetch":
return "Fetching"
default:
return ""
}
case agentcore.TurnStartEvent:
return "Thinking"
default:
return ""
}
}
// estimateTokens gives a coarse output-token estimate from a running character
// count of the child's streamed text (~4 chars per token). It rides along on
// each progress event as a rough "↓ tokens" figure; 0 means unknown (no text
// streamed yet).
func estimateTokens(chars int) int {
if chars <= 0 {
return 0
}
return chars / 4
}
+233
View File
@@ -0,0 +1,233 @@
package runtime
// Tests for the generic task tool (US-002/003/004, #454): its identity/schema
// contract, the shared concurrency semaphore (N > cap never exceeds cap), the
// nesting guard (child tool set excludes "task"), that a task returns the
// child's final text, and that a failed child surfaces as a tool error. The
// child loop is driven through the faux provider seam (mirrors orchestration_test.go);
// only the provider boundary is faked.
import (
"context"
"encoding/json"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/provider"
)
// TestTaskToolContract pins the tool identity, parallel execution mode, and the
// {description?, prompt} schema with prompt required.
func TestTaskToolContract(t *testing.T) {
tool := NewTaskTool(func() RunConfig { return RunConfig{} }, nil)
if tool.Name() != "task" {
t.Errorf("Name() = %q, want task", tool.Name())
}
if tool.ExecutionMode() != agentcore.ToolExecutionParallel {
t.Errorf("ExecutionMode() = %v, want parallel", tool.ExecutionMode())
}
var schema struct {
Properties struct {
Description json.RawMessage `json:"description"`
Prompt json.RawMessage `json:"prompt"`
} `json:"properties"`
Required []string `json:"required"`
}
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
t.Fatalf("schema is not valid JSON: %v", err)
}
if len(schema.Properties.Prompt) == 0 || len(schema.Properties.Description) == 0 {
t.Errorf("schema must declare both prompt and description properties")
}
if len(schema.Required) != 1 || schema.Required[0] != "prompt" {
t.Errorf("required = %v, want [prompt]", schema.Required)
}
}
// TestTaskReturnsChildText verifies a dispatched task drives an independent child
// loop and returns the child's final assistant text as the tool result.
func TestTaskReturnsChildText(t *testing.T) {
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{textTurn("child final report")},
}
factory := func() RunConfig {
return RunConfig{
LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}},
}
}
tool := NewTaskTool(factory, nil)
res, err := tool.Execute(context.Background(), "id", json.RawMessage(`{"description":"do x","prompt":"do the work"}`), nil)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if got := agentcore.ContentToText(res.Content); got != "child final report" {
t.Errorf("task result = %q, want 'child final report'", got)
}
if child.callCount() != 1 {
t.Errorf("child provider calls = %d, want 1", child.callCount())
}
}
// TestTaskFailedChildErrors verifies a child whose final turn stops on error is
// surfaced to the parent as a tool error (not a silent success).
func TestTaskFailedChildErrors(t *testing.T) {
// A child turn ending on StopReason=error, carrying diagnostic text as content
// (executeGoroutine surfaces the child's Content on failure).
errTurn := func(text string) fauxTurn {
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
withText := partial
withText.Content = agentcore.ContentList{agentcore.NewTextContent(text)}
final := withText
final.StopReason = agentcore.StopReasonError
return fauxTurn{
provider.StreamStartEvent{Partial: partial},
provider.StreamTextEvent{Partial: withText},
provider.StreamDoneEvent{Message: final},
}
}
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{errTurn("child exploded")},
}
factory := func() RunConfig {
return RunConfig{
LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}},
}
}
tool := NewTaskTool(factory, nil)
_, err := tool.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
if err == nil {
t.Fatal("a child that stopped on error must surface as a tool error")
}
if !strings.Contains(err.Error(), "child exploded") {
t.Errorf("error should carry the child's diagnostic, got %v", err)
}
}
// TestTaskSemaphoreBoundsConcurrency dispatches N tasks concurrently through a
// shared semaphore of capacity cap (< N) and asserts the number of children
// running at once never exceeds cap. Each child calls a blocking fake tool that
// parks on a barrier, so all admitted children pile up simultaneously and the
// peak concurrency is observable.
func TestTaskSemaphoreBoundsConcurrency(t *testing.T) {
const capN, n = 2, 6
sem := make(chan struct{}, capN)
var running, peak int64
release := make(chan struct{})
// blockTool parks until the test closes release, holding a semaphore slot for
// the duration and recording the peak number of concurrent children.
blockTool := execTool{
name: "block",
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
cur := atomic.AddInt64(&running, 1)
for {
p := atomic.LoadInt64(&peak)
if cur <= p || atomic.CompareAndSwapInt64(&peak, p, cur) {
break
}
}
defer atomic.AddInt64(&running, -1)
select {
case <-release:
case <-ctx.Done():
}
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("blocked")}}, nil
},
}
// Each child runs one turn that calls the blocking tool, then (after release)
// a final text turn.
factory := func() RunConfig {
p := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "c", ID: "c"}},
turns: []fauxTurn{toolCallTurn("t", "block", `{}`), textTurn("done")},
}
reg := agenttool.NewToolRegistry()
_ = reg.Register(blockTool)
return RunConfig{
LoopConfig: LoopConfig{Model: "c", Stream: provider.StreamFnFromProvider(p)},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}},
}
}
tool := NewTaskTool(factory, sem)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = tool.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
}()
}
// Give the admitted children time to reach the barrier, then let them go.
deadline := time.After(2 * time.Second)
for atomic.LoadInt64(&running) < int64(capN) {
select {
case <-deadline:
t.Fatalf("only %d children started, expected the semaphore to admit %d", atomic.LoadInt64(&running), capN)
default:
time.Sleep(time.Millisecond)
}
}
// Hold briefly so any over-admission (a semaphore bug) would push peak > cap.
time.Sleep(50 * time.Millisecond)
close(release)
wg.Wait()
if got := atomic.LoadInt64(&peak); got > int64(capN) {
t.Errorf("peak concurrent children = %d, must not exceed cap %d", got, capN)
}
if got := atomic.LoadInt64(&peak); got == 0 {
t.Error("no child ever ran; the semaphore blocked everything")
}
}
// TestTaskAdvertisesRegistryTools verifies the child sub-agent is told about the
// tools it can actually run: when the spec pins no explicit tool set, the child
// context's Tools are populated from the run config's registry. Without this the
// model receives an empty tool list and cannot do real work (the "non-functional
// sub-agent" bug), so this guards the wiring, not just the result.
func TestTaskAdvertisesRegistryTools(t *testing.T) {
// Capture the tools the provider is handed for the child request.
var gotTools []agentcore.AgentTool
capturing := provider.StreamFn(func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
gotTools = llm.Tools
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{textTurn("done")},
}
return provider.StreamFnFromProvider(child)(ctx, model, llm, cfg)
})
reg := agenttool.NewToolRegistry()
_ = reg.Register(echoTool("read", agentcore.ToolExecutionParallel, false))
_ = reg.Register(echoTool("bash", agentcore.ToolExecutionParallel, false))
factory := func() RunConfig {
return RunConfig{
LoopConfig: LoopConfig{Model: "child", Stream: capturing},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}},
}
}
tool := NewTaskTool(factory, nil)
if _, err := tool.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil); err != nil {
t.Fatalf("Execute err = %v", err)
}
if len(gotTools) != 2 {
t.Fatalf("child was advertised %d tools, want 2 (from the registry)", len(gotTools))
}
names := map[string]bool{gotTools[0].Name(): true, gotTools[1].Name(): true}
if !names["read"] || !names["bash"] {
t.Errorf("child tools = %v, want read+bash from the registry", names)
}
}
+137
View File
@@ -0,0 +1,137 @@
// This file implements structured telemetry collection for a loop run
// (observability -- structured telemetry collection). A lightweight accumulator observes the AgentEvents the loop
// already emits and folds them into a compact summary — per-tool wall-clock
// durations, turn count, truncation count, compaction count, and the latest
// context-utilization ratio — that is surfaced once at run end as a
// TelemetryEvent (just before agent_end).
//
// The design is deliberately additive: telemetry rides the existing AgentEvent
// family and the existing stream-json output path, so no new dependency
// (Prometheus/OTLP) is introduced and existing stream-json consumers that do
// not know the "telemetry" event type keep working unchanged. Collection is
// passive — observing an event never changes loop behavior.
package runtime
import (
"sync"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
// telemetry is the per-run accumulator. Most fields are folded from events on
// the single runLoop goroutine, but tool_execution_* events fire from the
// parallel tool-batch goroutines (ExecuteToolCalls), so all mutation goes
// through mu.
type telemetry struct {
mu sync.Mutex
turns int
truncationCount int
compactionCount int
// toolStarts maps an in-flight tool call id to the wall-clock time its
// execution began, so the matching end event can compute a duration. Keying
// by call id (not tool name) keeps parallel tool batches correct.
toolStarts map[string]time.Time
// toolTimings aggregates finished tool durations by tool name.
toolTimings map[string]agentcore.ToolTiming
// contextTokens / contextWindow capture the most recent context accounting so
// the summary can report the latest utilization ratio. contextWindow == 0
// means the window is unknown (utilization is then reported as 0).
contextTokens int
contextWindow int
// now is the clock, injectable for deterministic tests. Defaults to
// time.Now.
now func() time.Time
}
// newTelemetry constructs an empty accumulator using the wall clock.
func newTelemetry() *telemetry {
return &telemetry{
toolStarts: make(map[string]time.Time),
toolTimings: make(map[string]agentcore.ToolTiming),
now: time.Now,
}
}
// observe folds a single emitted event into the accumulator. It is a no-op for
// event types that carry no telemetry signal, and it never mutates the event.
func (t *telemetry) observe(ev agentcore.AgentEvent) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
switch e := ev.(type) {
case agentcore.TurnStartEvent:
t.turns++
case agentcore.ToolExecutionStartEvent:
t.toolStarts[e.ToolCallID] = t.now()
case agentcore.ToolExecutionEndEvent:
start, ok := t.toolStarts[e.ToolCallID]
if !ok {
return
}
delete(t.toolStarts, e.ToolCallID)
elapsed := t.now().Sub(start).Milliseconds()
if elapsed < 0 {
elapsed = 0
}
agg := t.toolTimings[e.ToolName]
agg.Count++
agg.TotalMs += elapsed
t.toolTimings[e.ToolName] = agg
case agentcore.TurnEndEvent:
if e.Message.StopReason == agentcore.StopReasonLength {
t.truncationCount++
}
case agentcore.CompactionEvent:
// Count only successful compactions; a failed one (ErrorMessage set) left
// the context unchanged.
if e.ErrorMessage == "" {
t.compactionCount++
}
}
}
// recordContext captures the latest context-token usage and window so the
// summary can report the current utilization ratio. A non-positive window is
// treated as unknown.
func (t *telemetry) recordContext(tokens, window int) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.contextTokens = tokens
if window > 0 {
t.contextWindow = window
}
}
// summary materializes the accumulated metrics into a TelemetryEvent. The
// per-tool map is copied so the emitted event does not alias the accumulator's
// live state.
func (t *telemetry) summary() agentcore.TelemetryEvent {
t.mu.Lock()
defer t.mu.Unlock()
timings := make(map[string]agentcore.ToolTiming, len(t.toolTimings))
for name, v := range t.toolTimings {
timings[name] = v
}
var utilization float64
if t.contextWindow > 0 {
utilization = float64(t.contextTokens) / float64(t.contextWindow)
}
return agentcore.TelemetryEvent{
Turns: t.turns,
ToolDurationsMs: timings,
TruncationCount: t.truncationCount,
CompactionCount: t.compactionCount,
ContextUtilization: utilization,
ContextTokens: t.contextTokens,
ContextWindow: t.contextWindow,
}
}
+276
View File
@@ -0,0 +1,276 @@
package runtime
// Tests for structured telemetry collection (observability -- structured telemetry collection, node-251):
// the loop accumulates per-tool durations, turn count, truncation count,
// compaction count, and the latest context-utilization ratio, then surfaces
// them as a TelemetryEvent emitted just before agent_end. Both the unit-level
// accumulator and the end-to-end loop wiring (including the stream-json
// headless surface a script reads) are covered.
import (
"bufio"
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/provider"
)
// fakeClock returns a now() closure that advances by step on every call, so a
// tool_execution_start/end pair yields a deterministic non-zero duration.
func fakeClock(start time.Time, step time.Duration) func() time.Time {
cur := start
return func() time.Time {
t := cur
cur = cur.Add(step)
return t
}
}
// findTelemetry returns the first TelemetryEvent emitted, or nil.
func findTelemetry(events []agentcore.AgentEvent) *agentcore.TelemetryEvent {
for _, ev := range events {
if te, ok := ev.(agentcore.TelemetryEvent); ok {
return &te
}
}
return nil
}
// TestTelemetryObserveToolTiming verifies a start/end pair records an aggregated
// per-tool duration, and repeated calls accumulate count and total.
func TestTelemetryObserveToolTiming(t *testing.T) {
tel := newTelemetry()
tel.now = fakeClock(time.Unix(0, 0), 5*time.Millisecond)
// Two invocations of "echo": each start advances the clock 5ms, each end
// advances another 5ms, so each invocation measures 5ms.
for _, id := range []string{"c1", "c2"} {
tel.observe(agentcore.ToolExecutionStartEvent{ToolCallID: id, ToolName: "echo"})
tel.observe(agentcore.ToolExecutionEndEvent{ToolCallID: id, ToolName: "echo"})
}
sum := tel.summary()
got, ok := sum.ToolDurationsMs["echo"]
if !ok {
t.Fatalf("expected timing for tool echo, got %+v", sum.ToolDurationsMs)
}
if got.Count != 2 {
t.Errorf("echo count = %d, want 2", got.Count)
}
if got.TotalMs != 10 {
t.Errorf("echo totalMs = %d, want 10", got.TotalMs)
}
}
// TestTelemetryObserveCounters verifies turn/truncation/compaction counters and
// that a failed compaction is not counted.
func TestTelemetryObserveCounters(t *testing.T) {
tel := newTelemetry()
tel.observe(agentcore.TurnStartEvent{})
tel.observe(agentcore.TurnStartEvent{})
tel.observe(agentcore.TurnEndEvent{Message: agentcore.AssistantMessage{StopReason: agentcore.StopReasonLength}})
tel.observe(agentcore.TurnEndEvent{Message: agentcore.AssistantMessage{StopReason: agentcore.StopReasonEndTurn}})
tel.observe(agentcore.CompactionEvent{}) // success
tel.observe(agentcore.CompactionEvent{ErrorMessage: "boom"}) // failure, not counted
sum := tel.summary()
if sum.Turns != 2 {
t.Errorf("turns = %d, want 2", sum.Turns)
}
if sum.TruncationCount != 1 {
t.Errorf("truncationCount = %d, want 1", sum.TruncationCount)
}
if sum.CompactionCount != 1 {
t.Errorf("compactionCount = %d, want 1 (failed compaction must not count)", sum.CompactionCount)
}
}
// TestTelemetryContextUtilization verifies the ratio is used/window and is 0
// when the window is unknown.
func TestTelemetryContextUtilization(t *testing.T) {
tel := newTelemetry()
tel.recordContext(500, 2000)
if got := tel.summary().ContextUtilization; got != 0.25 {
t.Errorf("utilization = %v, want 0.25", got)
}
unknown := newTelemetry()
unknown.recordContext(500, 0)
if got := unknown.summary().ContextUtilization; got != 0 {
t.Errorf("utilization with unknown window = %v, want 0", got)
}
}
// TestLoopEmitsTelemetryBeforeAgentEnd verifies the loop emits exactly one
// telemetry event immediately before agent_end and that it captures a tool
// execution.
func TestLoopEmitsTelemetryBeforeAgentEnd(t *testing.T) {
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
oneToolAssistant("c1", "echo"),
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("done")}},
}), echoTool("echo", agentcore.ToolExecutionParallel, false))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
te := findTelemetry(events)
if te == nil {
t.Fatalf("expected a TelemetryEvent, got %+v", eventKinds(events))
}
// Telemetry must be the penultimate event, immediately before agent_end.
if n := len(events); n < 2 || events[n-1].EventType() != agentcore.EventAgentEnd || events[n-2].EventType() != agentcore.EventTelemetry {
t.Errorf("telemetry must be emitted just before agent_end, got %v", eventKinds(events))
}
if te.Turns != 2 {
t.Errorf("telemetry turns = %d, want 2", te.Turns)
}
if _, ok := te.ToolDurationsMs["echo"]; !ok {
t.Errorf("telemetry should record the echo tool timing, got %+v", te.ToolDurationsMs)
}
if te.ToolDurationsMs["echo"].Count != 1 {
t.Errorf("echo count = %d, want 1", te.ToolDurationsMs["echo"].Count)
}
}
// TestLoopTelemetryCountsCompaction verifies a compaction that fires during the
// run increments the telemetry compaction counter and records a non-zero
// context-utilization ratio.
func TestLoopTelemetryCountsCompaction(t *testing.T) {
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
cfg := newRunCfg(main)
cfg.SummaryStream = summaryStream("## Goal\ncompacted")
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(12, 800)}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
te := findTelemetry(events)
if te == nil {
t.Fatal("expected a TelemetryEvent")
}
if te.CompactionCount != 1 {
t.Errorf("telemetry compactionCount = %d, want 1", te.CompactionCount)
}
if te.ContextWindow != 2000 {
t.Errorf("telemetry contextWindow = %d, want 2000", te.ContextWindow)
}
if te.ContextUtilization <= 0 {
t.Errorf("telemetry contextUtilization = %v, want > 0", te.ContextUtilization)
}
}
// TestLoopTelemetryCountsTruncation verifies a length-truncated assistant
// response increments the telemetry truncation counter.
func TestLoopTelemetryCountsTruncation(t *testing.T) {
truncated := oneToolAssistant("c1", "echo")
truncated.StopReason = agentcore.StopReasonLength
cfg := newRunCfg(scriptedStream([]agentcore.AssistantMessage{
truncated,
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn},
}), echoTool("echo", agentcore.ToolExecutionParallel, false))
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
te := findTelemetry(events)
if te == nil {
t.Fatal("expected a TelemetryEvent")
}
if te.TruncationCount != 1 {
t.Errorf("telemetry truncationCount = %d, want 1", te.TruncationCount)
}
}
// TestTelemetryEventEnvelope verifies the stream-json envelope carries every
// telemetry field, including a name-keyed per-tool timings object, so a script
// can read the metrics directly (headless surface, acceptance criterion 3).
func TestTelemetryEventEnvelope(t *testing.T) {
env := eventEnvelope(agentcore.TelemetryEvent{
Turns: 3,
TruncationCount: 1,
CompactionCount: 2,
ContextUtilization: 0.5,
ContextTokens: 1000,
ContextWindow: 2000,
ToolDurationsMs: map[string]agentcore.ToolTiming{"echo": {Count: 2, TotalMs: 40}},
})
if env["type"] != agentcore.EventTelemetry {
t.Errorf("type = %v, want %q", env["type"], agentcore.EventTelemetry)
}
if env["turns"] != 3 || env["truncationCount"] != 1 || env["compactionCount"] != 2 {
t.Errorf("counter fields wrong: %+v", env)
}
if env["contextUtilization"] != 0.5 || env["contextTokens"] != 1000 || env["contextWindow"] != 2000 {
t.Errorf("context fields wrong: %+v", env)
}
tools, ok := env["toolDurationsMs"].(map[string]map[string]any)
if !ok {
t.Fatalf("toolDurationsMs type = %T, want map[string]map[string]any", env["toolDurationsMs"])
}
if tools["echo"]["count"] != 2 || tools["echo"]["totalMs"] != int64(40) {
t.Errorf("echo timing wrong: %+v", tools["echo"])
}
}
// TestHeadlessStreamJSONSurfacesTelemetry verifies the run-end telemetry summary
// reaches the stream-json headless output as a parseable JSON line — the
// script-readable metric surface required by acceptance criterion 3.
func TestHeadlessStreamJSONSurfacesTelemetry(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{
toolCallTurn("call-1", "echo", `{"msg":"hi"}`),
textTurn("done"),
},
}
cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
var out bytes.Buffer
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: StreamJSONMode, Out: &out}); err != nil {
t.Fatalf("RunHeadless stream-json: %v", err)
}
var telemetryLine map[string]any
sc := bufio.NewScanner(&out)
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue
}
var env map[string]any
if err := json.Unmarshal(line, &env); err != nil {
t.Fatalf("stream-json line not valid JSON: %q (%v)", line, err)
}
if env["type"] == agentcore.EventTelemetry {
telemetryLine = env
}
}
if telemetryLine == nil {
t.Fatal("stream-json output must contain a telemetry event a script can read")
}
// turns is a JSON number; a script would read it as float64.
if turns, ok := telemetryLine["turns"].(float64); !ok || turns < 2 {
t.Errorf("telemetry turns = %v, want >= 2", telemetryLine["turns"])
}
tools, ok := telemetryLine["toolDurationsMs"].(map[string]any)
if !ok || tools["echo"] == nil {
t.Errorf("telemetry must report the echo tool timing, got %v", telemetryLine["toolDurationsMs"])
}
}
// eventKinds maps events to their type strings for readable failure output.
func eventKinds(events []agentcore.AgentEvent) []string {
out := make([]string, len(events))
for i, ev := range events {
out[i] = ev.EventType()
}
return out
}
+182
View File
@@ -0,0 +1,182 @@
// This file implements the prompt-template expansion engine (US-002, #332): it
// turns a template body plus tokenized invocation args into the final prompt
// text, supporting pi's positional/default/slice syntax.
//
// Supported placeholders (mirrors https://pi.dev/docs/latest/prompt-templates):
// - $1, $2, ... $N : Nth positional arg (1-indexed; out-of-range -> "")
// - $@, $ARGUMENTS : all args joined by a single space
// - ${1:-default} : arg 1 when present and non-empty, else `default`
// - ${@:-default}, ${ARGUMENTS:-default} : all args when non-empty, else default
// - ${@:N} : args from the Nth onward (1-indexed), joined
// - ${@:N:L} : L args starting at N, joined
//
// A single left-to-right pass expands both braced ${...} and bare $N/$@ forms.
// Because the pass consumes a ${...} as one unit, a bare $1 never matches inside
// ${1:-...}, and a default literal containing $ is not re-expanded (the
// substituted text is appended verbatim and the scan advances past it).
package runtime
import (
"strconv"
"strings"
)
// ExpandTemplate expands template against the tokenized args. A template with no
// placeholder preserves ParseUserCommand's behavior: with no args it is returned
// verbatim, with args they are appended after a blank line (joined by spaces).
func ExpandTemplate(template string, args []string) string {
if !hasPlaceholder(template) {
if len(args) == 0 {
return template
}
return template + "\n\n" + strings.Join(args, " ")
}
var b strings.Builder
i := 0
n := len(template)
for i < n {
c := template[i]
if c == '$' && i+1 < n {
next := template[i+1]
if next == '{' {
end := strings.IndexByte(template[i+2:], '}')
if end < 0 {
// No closing brace: emit the '$' literally and continue.
b.WriteByte(c)
i++
continue
}
inner := template[i+2 : i+2+end]
b.WriteString(expandBraced(inner, args))
i += 2 + end + 1
continue
}
if next == '@' {
b.WriteString(strings.Join(args, " "))
i += 2
continue
}
if isDigitByte(next) {
j := i + 1
for j < n && isDigitByte(template[j]) {
j++
}
idx, _ := strconv.Atoi(template[i+1 : j])
if idx >= 1 && idx <= len(args) {
b.WriteString(args[idx-1])
}
i = j
continue
}
if strings.HasPrefix(template[i+1:], "ARGUMENTS") {
b.WriteString(strings.Join(args, " "))
i += 1 + len("ARGUMENTS")
continue
}
}
b.WriteByte(c)
i++
}
return b.String()
}
// hasPlaceholder reports whether s contains any template placeholder: ${...},
// $@, $<digits>, or $ARGUMENTS. A bare '$' not followed by one of these is not a
// placeholder (emitted literally), so a template like "price $5" still counts.
func hasPlaceholder(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] != '$' {
continue
}
if i+1 >= len(s) {
return false
}
next := s[i+1]
if next == '{' || next == '@' || isDigitByte(next) {
return true
}
if strings.HasPrefix(s[i+1:], "ARGUMENTS") {
return true
}
}
return false
}
// expandBraced expands the content inside ${...} (without the surrounding
// braces). It dispatches on the three forms: name:-default, @:N[:L], or plain.
func expandBraced(inner string, args []string) string {
// default form: name:-default
if idx := strings.Index(inner, ":-"); idx >= 0 {
return expandDefaulted(inner[:idx], inner[idx+2:], args)
}
// slice form: @:N or @:N:L
if idx := strings.Index(inner, ":"); idx >= 0 {
if name := inner[:idx]; name != "@" && name != "ARGUMENTS" {
return "" // slicing only applies to all-args
}
return expandSlice(inner[idx+1:], args)
}
// plain: N, @, or ARGUMENTS
return expandPlain(inner, args)
}
// expandPlain expands a bare braced name (no :- or :): a positional index, @,
// or ARGUMENTS. An unknown name (e.g. ${foo}) expands to "".
func expandPlain(name string, args []string) string {
if name == "@" || name == "ARGUMENTS" {
return strings.Join(args, " ")
}
if idx, err := strconv.Atoi(name); err == nil {
if idx >= 1 && idx <= len(args) {
return args[idx-1]
}
return ""
}
return ""
}
// expandDefaulted expands name:-default: the named positional (when in range and
// non-empty) or all-args (when the join is non-empty), otherwise the literal
// default. The default is not re-expanded.
func expandDefaulted(name, def string, args []string) string {
if name == "@" || name == "ARGUMENTS" {
if joined := strings.Join(args, " "); joined != "" {
return joined
}
return def
}
if idx, err := strconv.Atoi(name); err == nil {
if idx >= 1 && idx <= len(args) && args[idx-1] != "" {
return args[idx-1]
}
return def
}
return def
}
// expandSlice expands the N (or N:L) part of ${@:N} / ${@:N:L}: args from the Nth
// onward (1-indexed), optionally limited to L, joined by spaces. N<1 or beyond
// the arg list yields "".
func expandSlice(rest string, args []string) string {
parts := strings.SplitN(rest, ":", 2)
start, _ := strconv.Atoi(parts[0])
if start < 1 {
return ""
}
begin := start - 1
if begin >= len(args) {
return ""
}
end := len(args)
if len(parts) == 2 {
if l, err := strconv.Atoi(parts[1]); err == nil && l >= 0 {
end = begin + l
if end > len(args) {
end = len(args)
}
}
}
return strings.Join(args[begin:end], " ")
}
func isDigitByte(b byte) bool { return b >= '0' && b <= '9' }
+170
View File
@@ -0,0 +1,170 @@
package runtime
// Tests for the prompt-template expansion engine (US-002, #332). Covers the
// positional/default/slice syntax from the acceptance criteria, the
// ${...}-before-bare-$ ordering invariant, and the no-placeholder append
// behavior that preserves ParseUserCommand's existing semantics.
import "testing"
func TestExpandTemplateNoPlaceholder(t *testing.T) {
// No args: verbatim.
if got := ExpandTemplate("Take a note", nil); got != "Take a note" {
t.Errorf("no args: got %q", got)
}
if got := ExpandTemplate("Take a note", []string{}); got != "Take a note" {
t.Errorf("empty args: got %q", got)
}
// With args: appended after a blank line, joined by spaces.
if got := ExpandTemplate("Take a note", []string{"buy", "milk"}); got != "Take a note\n\nbuy milk" {
t.Errorf("with args: got %q", got)
}
}
func TestExpandTemplatePositional(t *testing.T) {
args := []string{"Button", "click", "handler"}
if got := ExpandTemplate("$1", args); got != "Button" {
t.Errorf("$1 = %q, want Button", got)
}
if got := ExpandTemplate("$3", args); got != "handler" {
t.Errorf("$3 = %q, want handler", got)
}
// Out of range -> empty.
if got := ExpandTemplate("$5", args); got != "" {
t.Errorf("$5 = %q, want empty", got)
}
// Multi-digit.
if got := ExpandTemplate("$10", []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}); got != "j" {
t.Errorf("$10 = %q, want j", got)
}
}
func TestExpandTemplateAllArgs(t *testing.T) {
args := []string{"Button", "click", "handler"}
if got := ExpandTemplate("$@", args); got != "Button click handler" {
t.Errorf("$@ = %q", got)
}
if got := ExpandTemplate("$ARGUMENTS", args); got != "Button click handler" {
t.Errorf("$ARGUMENTS = %q", got)
}
}
func TestExpandTemplateDefaultPositional(t *testing.T) {
// Present and non-empty -> the arg.
if got := ExpandTemplate("${1:-default}", []string{"a"}); got != "a" {
t.Errorf("present: got %q", got)
}
// Absent -> default.
if got := ExpandTemplate("${1:-default}", nil); got != "default" {
t.Errorf("absent: got %q", got)
}
// Present but empty -> default.
if got := ExpandTemplate("${1:-default}", []string{""}); got != "default" {
t.Errorf("empty: got %q", got)
}
// Default containing a $ is not re-expanded.
if got := ExpandTemplate("${1:-pay $5}", nil); got != "pay $5" {
t.Errorf("default literal $: got %q", got)
}
}
func TestExpandTemplateDefaultAllArgs(t *testing.T) {
if got := ExpandTemplate("${@:-default}", []string{"a", "b"}); got != "a b" {
t.Errorf("present: got %q", got)
}
if got := ExpandTemplate("${@:-default}", nil); got != "default" {
t.Errorf("absent: got %q", got)
}
if got := ExpandTemplate("${ARGUMENTS:-default}", []string{"x"}); got != "x" {
t.Errorf("ARGUMENTS present: got %q", got)
}
if got := ExpandTemplate("${ARGUMENTS:-default}", nil); got != "default" {
t.Errorf("ARGUMENTS absent: got %q", got)
}
}
func TestExpandTemplateSlice(t *testing.T) {
args := []string{"a", "b", "c", "d", "e"}
// ${@:N}: from Nth onward.
if got := ExpandTemplate("${@:2}", args); got != "b c d e" {
t.Errorf("${@:2} = %q", got)
}
if got := ExpandTemplate("${@:1}", args); got != "a b c d e" {
t.Errorf("${@:1} = %q", got)
}
// ${@:N:L}: L args starting at N.
if got := ExpandTemplate("${@:2:2}", args); got != "b c" {
t.Errorf("${@:2:2} = %q", got)
}
if got := ExpandTemplate("${@:1:3}", args); got != "a b c" {
t.Errorf("${@:1:3} = %q", got)
}
// N beyond args -> empty.
if got := ExpandTemplate("${@:9}", args); got != "" {
t.Errorf("${@:9} = %q, want empty", got)
}
// L clamps to available.
if got := ExpandTemplate("${@:3:100}", args); got != "c d e" {
t.Errorf("${@:3:100} = %q, want c d e", got)
}
// N<1 -> empty.
if got := ExpandTemplate("${@:0}", args); got != "" {
t.Errorf("${@:0} = %q, want empty", got)
}
}
func TestExpandTemplateOrderingNoDoubleMatch(t *testing.T) {
// A bare $1 must NOT match inside ${1:-...}. With args present, ${1:-$2}
// uses arg1 ("a"); the $2 default is never consulted nor expanded.
if got := ExpandTemplate("${1:-$2}", []string{"a"}); got != "a" {
t.Errorf("${1:-$2} with arg1=a: got %q, want a", got)
}
// With arg1 absent, the default "$2" is taken literally (not expanded to "").
if got := ExpandTemplate("${1:-$2}", nil); got != "$2" {
t.Errorf("${1:-$2} absent: got %q, want literal $2", got)
}
// Braced and bare coexist in one pass.
if got := ExpandTemplate("${1:-x} and $2", []string{"a", "b"}); got != "a and b" {
t.Errorf("coexist: got %q", got)
}
}
func TestExpandTemplateExampleFromSpec(t *testing.T) {
// $@ expands to ALL args joined (AC: "all args joined by a single space").
if got := ExpandTemplate("echo $@", []string{"a", "b", "c"}); got != "echo a b c" {
t.Errorf("$@ all-args: got %q", got)
}
// The pi "component" example: $1 for the name, features are the remaining
// args expressed with the slice form ${@:2} (the right tool for "rest").
tmpl := "Create a React component named $1 with features: ${@:2}"
if got := ExpandTemplate(tmpl, []string{"Button", "click", "handler"}); got != "Create a React component named Button with features: click handler" {
t.Errorf("component example: got %q", got)
}
// "Summarize the current state in ${1:-7} bullet points."
bullets := "Summarize the current state in ${1:-7} bullet points."
if got := ExpandTemplate(bullets, nil); got != "Summarize the current state in 7 bullet points." {
t.Errorf("default bullets: got %q", got)
}
if got := ExpandTemplate(bullets, []string{"5"}); got != "Summarize the current state in 5 bullet points." {
t.Errorf("explicit bullets: got %q", got)
}
}
func TestExpandTemplateLiteralDollar(t *testing.T) {
// A '$' not forming a placeholder is emitted literally. Such a template has
// no placeholder, so args (if any) are appended per the no-placeholder rule.
if got := ExpandTemplate("100$ off", nil); got != "100$ off" {
t.Errorf("literal $ mid-string: got %q", got)
}
if got := ExpandTemplate("trailing$", nil); got != "trailing$" {
t.Errorf("trailing $ no args: got %q", got)
}
// No placeholder + args -> append after a blank line.
if got := ExpandTemplate("trailing$", []string{"x"}); got != "trailing$\n\nx" {
t.Errorf("trailing $ with args: got %q", got)
}
// A literal '$' alongside a real placeholder: '$' before a space stays '$'.
if got := ExpandTemplate("cost $ and $1", []string{"five"}); got != "cost $ and five" {
t.Errorf("literal $ next to placeholder: got %q", got)
}
}
+52
View File
@@ -0,0 +1,52 @@
package runtime
import (
"context"
"encoding/json"
"github.com/smallnest/pigo/internal/agentcore"
)
// execTool is a configurable AgentTool used by the loop/headless tests that
// remain in package agent. Its canonical definition moved to
// internal/agenttool with tool_executor_test.go (US-003 of the package split);
// this copy is re-provided here so the agent-resident tests keep compiling
// during the transition.
type execTool struct {
name string
schema string
run func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error)
mode agentcore.ToolExecutionMode
}
func (t execTool) Name() string { return t.name }
func (t execTool) Description() string { return "exec" }
func (t execTool) Schema() json.RawMessage {
if t.schema == "" {
return nil
}
return json.RawMessage(t.schema)
}
func (t execTool) ExecutionMode() agentcore.ToolExecutionMode {
if t.mode == "" {
return agentcore.ToolExecutionParallel
}
return t.mode
}
func (t execTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return t.run(ctx, id, args, onUpdate)
}
// echoTool returns its name as text; optionally terminates. Canonical
// definition moved with batch_executor_test.go; re-provided here for the
// agent-resident tests.
func echoTool(name string, mode agentcore.ToolExecutionMode, terminate bool) execTool {
return execTool{
name: name,
mode: mode,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
term := terminate
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}, Terminate: &term}, nil
},
}
}