first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 & y < z > "q" 'r'") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user