first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
@@ -0,0 +1,106 @@
package run
// Regression coverage for the #425 driver convergence (FR-16): the SAME resolved
// hook set must fire in every driver mode. Rather than stand up a provider-backed
// run for each of the six drivers, this pins the two DISTINCT wiring paths they
// route through — the one-shot path (headless / subagent_rpc via InstallDriverHooks
// / InstallHooks) and the multi-turn path (repl / tui / goal / btw via
// BuildDispatcher once + InstallSeams per turn) — and asserts a PreToolUse hook
// installed through either path reaches the BeforeToolCall seam and blocks. It
// also pins FR-18: an empty hook set wires no seam in either path, so a run with
// no hooks configured behaves exactly as before.
import (
"context"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// blockingPreToolUse is a hook set whose PreToolUse hook exits 2 (Claude Code
// block semantics), so any wired BeforeToolCall seam must return a blocking
// decision when it fires.
func blockingPreToolUse() hooks.HookSet {
return hooks.HookSet{
"PreToolUse": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: "exit 2"}}}},
}
}
// fireBeforeToolCall drives the wired BeforeToolCall seam once and reports
// whether it produced a blocking decision. A nil seam (no hook wired) reports
// false.
func fireBeforeToolCall(cfg *runtime.RunConfig) bool {
seam := cfg.Batch.ToolExecutorConfig.BeforeToolCall
if seam == nil {
return false
}
dec := seam(context.Background(), agentcore.AgentToolCall{Name: "Bash"})
return dec != nil && dec.Block
}
// TestHookConvergenceBothPaths asserts the same PreToolUse hook set fires in both
// driver wiring paths: the one-shot headless path and the multi-turn REPL path.
func TestHookConvergenceBothPaths(t *testing.T) {
deps := HookDeps{SessionID: "s1", ProjectDir: t.TempDir()}
set := blockingPreToolUse()
// Headless / subagent_rpc path: InstallDriverHooks wires the seams all-in-one.
t.Run("headless", func(t *testing.T) {
var cfg runtime.RunConfig
d, _ := InstallDriverHooks(context.Background(), &cfg, set, deps, "startup", nil)
if d == nil {
t.Fatal("expected dispatcher for non-empty hook set")
}
if !fireBeforeToolCall(&cfg) {
t.Fatal("headless path: PreToolUse hook did not block")
}
})
// REPL / TUI / goal / btw path: BuildDispatcher once, then InstallSeams per turn.
t.Run("repl", func(t *testing.T) {
d := BuildDispatcher(set, deps)
if d == nil {
t.Fatal("expected dispatcher for non-empty hook set")
}
var cfg runtime.RunConfig
InstallSeams(&cfg, d, deps)
if !fireBeforeToolCall(&cfg) {
t.Fatal("repl path: PreToolUse hook did not block")
}
})
}
// TestHookConvergenceNoHooksUnchanged pins FR-18: with no hooks configured neither
// wiring path installs a BeforeToolCall seam, so both drivers behave exactly as
// they did before hooks existed.
func TestHookConvergenceNoHooksUnchanged(t *testing.T) {
deps := HookDeps{ProjectDir: t.TempDir()}
t.Run("headless", func(t *testing.T) {
var cfg runtime.RunConfig
d, ev := InstallDriverHooks(context.Background(), &cfg, nil, deps, "startup", nil)
if d != nil {
t.Fatalf("expected nil dispatcher for empty hook set, got %v", d)
}
if ev != nil {
t.Fatal("expected event handler unchanged (nil) for empty hook set")
}
if cfg.Batch.ToolExecutorConfig.BeforeToolCall != nil {
t.Fatal("headless path: seam wired despite no hooks")
}
})
t.Run("repl", func(t *testing.T) {
d := BuildDispatcher(nil, deps)
if d != nil {
t.Fatalf("expected nil dispatcher for empty hook set, got %v", d)
}
var cfg runtime.RunConfig
InstallSeams(&cfg, d, deps) // nil dispatcher must be a no-op
if cfg.Batch.ToolExecutorConfig.BeforeToolCall != nil {
t.Fatal("repl path: seam wired despite no hooks")
}
})
}
+64
View File
@@ -0,0 +1,64 @@
// This file provides the single convergence entry point every driver calls to
// wire hooks into its run (#425, FR-16). Before this, each of the six RunConfig
// assembly sites (repl/goal/btw/tui/headless/subagent_rpc) built the loop config
// independently, which was the main risk of a hook point being silently dropped
// in one mode but not another. Routing them all through InstallDriverHooks makes
// "which hook points a run has" a single decision rather than six.
//
// InstallDriverHooks resolves the trust-gated hook set (FR-14), installs the
// tool-execution and Stop seams via InstallHooks, dispatches SessionStart inline
// so injected context reaches turn one (#423), and chains the observer notifier
// (SessionEnd/PreCompact, #424) onto the driver's event seam. It returns the
// Dispatcher so a caller can additionally run UserPromptSubmit (prompt entry) or
// InstallSubagentStop (sub-agent), and the possibly-wrapped event handler to
// install on whatever OnEvent seam the driver owns (HeadlessConfig.OnEvent for
// headless, the DrainStream handler for the REPL/TUI).
package run
import (
"context"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// InstallDriverHooks is the uniform hook-wiring seam for every driver. It
// installs the tool-execution + Stop hook points onto cfg, dispatches
// SessionStart (registering any additionalContext as a one-shot reminder so it
// lands in turn one), and chains the SessionEnd/PreCompact observer onto onEvent.
//
// set is the already-resolved, trust-gated hook set (see ResolveHookSet). When
// it is empty NewDispatcher returns nil, so this is a no-op that returns
// (nil, onEvent) and the hot path pays nothing (FR-18): the run behaves exactly
// as it did before hooks existed.
//
// The returned dispatcher (nil when no hooks) lets the caller wire the remaining
// prompt-scoped / sub-agent hooks. The returned handler is onEvent unchanged when
// there are no hooks, or onEvent with the notifier chained after it otherwise —
// so the driver installs one handler regardless.
func InstallDriverHooks(ctx context.Context, cfg *runtime.RunConfig, set hooks.HookSet, deps HookDeps, source string, onEvent func(agentcore.AgentEvent)) (*hooks.Dispatcher, func(agentcore.AgentEvent)) {
d := InstallHooks(cfg, set, deps)
if d == nil {
return nil, onEvent
}
DispatchSessionStart(ctx, d, cfg, deps, source)
n := hooks.NewHookNotifier(d, deps.SessionID, deps.ProjectDir)
return d, chainEvent(onEvent, n.Handle)
}
// chainEvent composes two AgentEvent observers into one that calls prev then
// next. A nil operand is identity, so chaining onto an unset seam returns the
// other unchanged (and returns nil when both are nil, keeping the seam unset).
func chainEvent(prev, next func(agentcore.AgentEvent)) func(agentcore.AgentEvent) {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ev agentcore.AgentEvent) {
prev(ev)
next(ev)
}
}
+223
View File
@@ -0,0 +1,223 @@
// This file provides the single cli-layer assembly helper that composes hook
// dispatch into a runtime.RunConfig. It lives in the cli layer (not runtime) to
// avoid a runtime→hooks→runtime import cycle: runtime stays hook-agnostic and
// only exposes the generic seams (BeforeToolCall/AfterToolCall/ShouldStopAfterTurn),
// while this helper knows about both the resolved Config.Hooks and the seams.
//
// #419 establishes the skeleton: InstallHooks builds a Dispatcher (or short-
// circuits to nil when no hooks are configured, FR-18) and offers generic
// decorator combinators for the seams. The concrete per-event wiring is filled
// in by later issues (#420#424); this file deliberately wires no event yet.
package run
import (
"context"
"encoding/json"
"io"
"os"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// HookDeps carries the run-scoped context a Dispatcher needs: the session id and
// project directory that populate each HookInput / the hook process environment,
// and the writer that receives isolation warnings. WarnLog may be nil (defaults
// to os.Stderr), matching the plugin.EventNotifier convention.
type HookDeps struct {
SessionID string
ProjectDir string
WarnLog io.Writer
}
// InstallHooks builds the Dispatcher for a run from the resolved hook set and
// wires the tool-execution hook points into cfg. It short-circuits when no hooks
// are configured: NewDispatcher returns nil for an empty set, so the hot path
// pays nothing (FR-18) and nothing is wrapped. The returned dispatcher is used
// by later per-event wiring (#421#424).
//
// PreToolUse is CHAINED onto the existing BeforeToolCall seam (occupied by the
// trust gate) rather than replacing it: trust runs first and stays authoritative
// (a trust block short-circuits before the user hook runs). PostToolUse is
// chained onto AfterToolCall as a last-writer so it can append feedback to an
// already-executed tool's result without undoing it.
func InstallHooks(cfg *runtime.RunConfig, set hooks.HookSet, deps HookDeps) *hooks.Dispatcher {
warn := deps.WarnLog
if warn == nil {
warn = os.Stderr
}
d := hooks.NewDispatcher(set, deps.ProjectDir, warn)
if d == nil {
return nil
}
InstallSeams(cfg, d, deps)
return d
}
// BuildDispatcher builds the run's Dispatcher from the resolved hook set without
// touching a RunConfig. It is the entry point for the multi-turn drivers (REPL /
// TUI) that resolve hooks and fire SessionStart once per session, then install
// the per-turn seams (InstallSeams) on each turn's freshly-built RunConfig. It
// returns nil for an empty set (FR-18), so callers gate all hook work on non-nil.
func BuildDispatcher(set hooks.HookSet, deps HookDeps) *hooks.Dispatcher {
warn := deps.WarnLog
if warn == nil {
warn = os.Stderr
}
return hooks.NewDispatcher(set, deps.ProjectDir, warn)
}
// InstallSeams wires the tool-execution and Stop hook points onto cfg from an
// already-built dispatcher. It is the shared body of InstallHooks and the
// per-turn install path for multi-turn drivers. A nil dispatcher is a no-op, so
// callers can invoke it unconditionally.
//
// PreToolUse is CHAINED onto the existing BeforeToolCall seam (occupied by the
// trust gate) rather than replacing it: trust runs first and stays authoritative
// (a trust block short-circuits before the user hook runs). PostToolUse is
// chained onto AfterToolCall as a last-writer so it can append feedback to an
// already-executed tool's result without undoing it. The Stop hook is chained
// onto the loop's natural-end seam (FR-10), bounded by the decorator's
// consecutive-block counter (FR-12).
func InstallSeams(cfg *runtime.RunConfig, d *hooks.Dispatcher, deps HookDeps) {
if d == nil {
return
}
tec := &cfg.Batch.ToolExecutorConfig
tec.BeforeToolCall = chainBeforeToolCall(tec.BeforeToolCall, preToolCallHook(d, deps))
tec.AfterToolCall = chainAfterToolCall(tec.AfterToolCall, postToolCallHook(d, deps))
installStopHook(cfg, d, deps, "Stop")
}
// preToolCallHook adapts the dispatcher's PreToolUse event to the BeforeToolCall
// seam. It dispatches with the tool name and raw arguments; a block becomes a
// blocking decision whose reason is surfaced as the tool's error result, and an
// updatedInput becomes an argument rewrite (re-validated by the executor).
func preToolCallHook(d *hooks.Dispatcher, deps HookDeps) agentcore.BeforeToolCallFunc {
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
dec := d.Dispatch(ctx, hooks.EventPreToolUse, call.Name, hooks.HookInput{
EventType: hooks.EventPreToolUse,
SessionID: deps.SessionID,
ProjectDir: deps.ProjectDir,
ToolName: call.Name,
ToolInput: call.Arguments,
})
if dec.Block {
content := agentcore.ContentList{agentcore.NewTextContent(hookReason(dec.Reason, call.Name))}
return &agentcore.BeforeToolCallDecision{Block: true, Content: &content}
}
if len(dec.UpdatedInput) > 0 {
return &agentcore.BeforeToolCallDecision{UpdatedInput: dec.UpdatedInput}
}
return nil
}
}
// postToolCallHook adapts the dispatcher's PostToolUse event to the AfterToolCall
// seam. It dispatches with the tool name, raw arguments, and the tool's response,
// then appends any reason/additionalContext to the result content as a new text
// block (the executed tool is never undone). A block on Post is treated as
// feedback only: it cannot retract an already-run tool, so we surface the reason.
func postToolCallHook(d *hooks.Dispatcher, deps HookDeps) agentcore.AfterToolCallFunc {
return func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
var resp json.RawMessage
if b, err := json.Marshal(result.Content); err == nil {
resp = b
}
dec := d.Dispatch(ctx, "PostToolUse", call.Name, hooks.HookInput{
EventType: "PostToolUse",
SessionID: deps.SessionID,
ProjectDir: deps.ProjectDir,
ToolName: call.Name,
ToolInput: call.Arguments,
ToolResponse: resp,
})
feedback := joinHookText(dec.Reason, dec.AdditionalContext)
if feedback == "" {
return nil
}
content := append(agentcore.ContentList{}, result.Content...)
content = append(content, agentcore.NewTextContent(feedback))
return &agentcore.AfterToolCallResult{Content: &content}
}
}
// hookReason returns the block reason, falling back to a generic message keyed on
// the tool name when the hook gave no reason.
func hookReason(reason, toolName string) string {
if reason != "" {
return reason
}
return "tool " + toolName + " blocked by PreToolUse hook"
}
// joinHookText joins two hook text fields with a newline, dropping empties.
func joinHookText(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
default:
return a + "\n" + b
}
}
// chainBeforeToolCall composes two BeforeToolCall seams into one that runs prev
// first, then next. A blocking decision from prev short-circuits (next does not
// run), so an earlier gate (e.g. trust) is authoritative over a later hook. A
// nil operand is treated as identity, so composing onto an unset seam returns
// the other unchanged.
func chainBeforeToolCall(prev, next agentcore.BeforeToolCallFunc) agentcore.BeforeToolCallFunc {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
if dec := prev(ctx, call); dec != nil && dec.Block {
return dec
}
return next(ctx, call)
}
}
// chainAfterToolCall composes two AfterToolCall seams into one that runs prev
// first, then next. next's non-nil result wins (last writer), so a hook layered
// after an existing seam can override it; when next returns nil, prev's result
// is preserved. A nil operand is identity.
func chainAfterToolCall(prev, next agentcore.AfterToolCallFunc) agentcore.AfterToolCallFunc {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
prevRes := prev(ctx, call, result, isError)
if nextRes := next(ctx, call, result, isError); nextRes != nil {
return nextRes
}
return prevRes
}
}
// chainShouldStop composes two ShouldStopAfterTurn seams with OR semantics: the
// run stops after a turn if either predicate says so. prev runs first and
// short-circuits when true. A nil operand is identity.
func chainShouldStop(prev, next func(context.Context, *agentcore.AgentContext) bool) func(context.Context, *agentcore.AgentContext) bool {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ctx context.Context, agentCtx *agentcore.AgentContext) bool {
if prev(ctx, agentCtx) {
return true
}
return next(ctx, agentCtx)
}
}
+108
View File
@@ -0,0 +1,108 @@
package run
import (
"context"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
func TestInstallHooksEmptyShortCircuits(t *testing.T) {
var cfg runtime.RunConfig
if d := InstallHooks(&cfg, nil, HookDeps{ProjectDir: t.TempDir()}); d != nil {
t.Fatalf("expected nil dispatcher for empty hook set, got %v", d)
}
if d := InstallHooks(&cfg, hooks.HookSet{}, HookDeps{}); d != nil {
t.Fatalf("expected nil dispatcher for empty map, got %v", d)
}
}
func TestInstallHooksBuildsDispatcher(t *testing.T) {
set := hooks.HookSet{
"PreToolUse": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: "true"}}}},
}
var cfg runtime.RunConfig
d := InstallHooks(&cfg, set, HookDeps{ProjectDir: t.TempDir()})
if d == nil {
t.Fatal("expected non-nil dispatcher for non-empty hook set")
}
}
func TestChainBeforeToolCall(t *testing.T) {
block := &agentcore.BeforeToolCallDecision{Block: true}
allow := func(context.Context, agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision { return nil }
deny := func(context.Context, agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision { return block }
// nil operands act as identity.
if got := chainBeforeToolCall(nil, deny); got == nil {
t.Fatal("nil prev should return next")
}
if got := chainBeforeToolCall(allow, nil); got == nil {
t.Fatal("nil next should return prev")
}
// prev blocks → short-circuit, next never runs.
nextRan := false
spy := func(context.Context, agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
nextRan = true
return nil
}
if dec := chainBeforeToolCall(deny, spy)(context.Background(), agentcore.AgentToolCall{}); dec == nil || !dec.Block {
t.Fatalf("expected block decision, got %v", dec)
}
if nextRan {
t.Fatal("next should not run after prev blocks")
}
// prev allows → next runs and decides.
if dec := chainBeforeToolCall(allow, deny)(context.Background(), agentcore.AgentToolCall{}); dec == nil || !dec.Block {
t.Fatalf("expected next's block decision, got %v", dec)
}
}
func TestChainAfterToolCall(t *testing.T) {
content := agentcore.ContentList{}
prevRes := &agentcore.AfterToolCallResult{Content: &content}
nextRes := &agentcore.AfterToolCallResult{Content: &content}
prev := func(context.Context, agentcore.AgentToolCall, agentcore.AgentToolResult, bool) *agentcore.AfterToolCallResult {
return prevRes
}
nilNext := func(context.Context, agentcore.AgentToolCall, agentcore.AgentToolResult, bool) *agentcore.AfterToolCallResult {
return nil
}
next := func(context.Context, agentcore.AgentToolCall, agentcore.AgentToolResult, bool) *agentcore.AfterToolCallResult {
return nextRes
}
// next returns nil → prev's result preserved.
if got := chainAfterToolCall(prev, nilNext)(context.Background(), agentcore.AgentToolCall{}, agentcore.AgentToolResult{}, false); got != prevRes {
t.Fatalf("expected prev result when next is nil, got %v", got)
}
// next returns non-nil → next wins.
if got := chainAfterToolCall(prev, next)(context.Background(), agentcore.AgentToolCall{}, agentcore.AgentToolResult{}, false); got != nextRes {
t.Fatalf("expected next result to win, got %v", got)
}
}
func TestChainShouldStop(t *testing.T) {
yes := func(context.Context, *agentcore.AgentContext) bool { return true }
no := func(context.Context, *agentcore.AgentContext) bool { return false }
if got := chainShouldStop(no, no)(context.Background(), nil); got {
t.Fatal("both false should be false")
}
if got := chainShouldStop(no, yes)(context.Background(), nil); !got {
t.Fatal("next true should stop")
}
// prev true short-circuits without consulting next.
nextRan := false
spy := func(context.Context, *agentcore.AgentContext) bool { nextRan = true; return false }
if got := chainShouldStop(yes, spy)(context.Background(), nil); !got {
t.Fatal("prev true should stop")
}
if nextRan {
t.Fatal("next should not run after prev returns true")
}
}
+55
View File
@@ -0,0 +1,55 @@
// This file provides the cli-layer helper that runs the UserPromptSubmit hook
// event (US-007, FR-9) at the two prompt entry points (REPL + headless) before a
// prompt is submitted to the loop. It lives in the cli layer alongside the other
// hook assembly (hooks_install.go) because it bridges the resolved Dispatcher and
// the runtime.RunConfig, which runtime itself must not know about.
//
// Two hook effects are supported, with block taking priority over injection:
//
// - block (decision=block / exit 2): the prompt is NOT submitted. The caller
// decides how to surface the reason — the REPL returns to its input state and
// shows it, the headless driver exits non-zero. DispatchUserPromptSubmit only
// reports (block, reason); it does not itself abort.
// - additionalContext: registered as a ONE-SHOT reminder on cfg.Reminders so the
// text is injected into THIS turn's provider context via the existing
// TransformContext seam, then never again. It is not written to the persisted
// message history (the reminder mechanism is ephemeral by construction).
package run
import (
"context"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// DispatchUserPromptSubmit runs the UserPromptSubmit event for a prompt about to
// be submitted. It returns (block, reason): when block is true the caller must
// NOT submit the prompt and should surface reason. When block is false and the
// hook returned additionalContext, that context is registered as a one-shot
// reminder on cfg.Reminders (allocating a registry when cfg.Reminders is nil) so
// it is injected into this turn only. Block takes priority: a blocking decision
// never also injects.
//
// A nil dispatcher (no hooks configured) is a no-op that returns (false, "").
func DispatchUserPromptSubmit(ctx context.Context, d *hooks.Dispatcher, cfg *runtime.RunConfig, deps HookDeps, prompt string) (block bool, reason string) {
if d == nil {
return false, ""
}
dec := d.Dispatch(ctx, "UserPromptSubmit", "", hooks.HookInput{
EventType: "UserPromptSubmit",
SessionID: deps.SessionID,
ProjectDir: deps.ProjectDir,
Prompt: prompt,
})
if dec.Block {
return true, hookReason(dec.Reason, "UserPromptSubmit")
}
if dec.AdditionalContext != "" && cfg != nil {
if cfg.Reminders == nil {
cfg.Reminders = runtime.NewReminderRegistry()
}
cfg.Reminders.Register(runtime.NewOneShotReminder("user-prompt-submit", dec.AdditionalContext))
}
return false, ""
}
@@ -0,0 +1,97 @@
package run
import (
"context"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// promptDispatcher builds a Dispatcher for a single UserPromptSubmit matcher.
func promptDispatcher(t *testing.T, cmd string) *hooks.Dispatcher {
t.Helper()
set := hooks.HookSet{
"UserPromptSubmit": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: cmd}}}},
}
d := hooks.NewDispatcher(set, t.TempDir(), nil)
if d == nil {
t.Fatal("expected non-nil dispatcher")
}
return d
}
// TestUserPromptSubmitBlocks: a hook exiting 2 with a stderr reason blocks the
// prompt; DispatchUserPromptSubmit reports (true, reason) and injects nothing.
func TestUserPromptSubmitBlocks(t *testing.T) {
d := promptDispatcher(t, `echo "prompt rejected" 1>&2; exit 2`)
var cfg runtime.RunConfig
block, reason := DispatchUserPromptSubmit(context.Background(), d, &cfg, HookDeps{SessionID: "s1"}, "hello")
if !block {
t.Fatal("expected block")
}
if !strings.Contains(reason, "prompt rejected") {
t.Fatalf("block reason not surfaced, got %q", reason)
}
if cfg.Reminders != nil && !cfg.Reminders.Empty() {
t.Fatal("a blocking decision must not register a reminder")
}
}
// TestUserPromptSubmitInjectsOnce: a hook printing additionalContext registers a
// one-shot reminder that fires exactly once, then goes silent.
func TestUserPromptSubmitInjectsOnce(t *testing.T) {
d := promptDispatcher(t, `echo '{"additionalContext":"remember: run gofmt"}'`)
var cfg runtime.RunConfig
block, _ := DispatchUserPromptSubmit(context.Background(), d, &cfg, HookDeps{SessionID: "s1"}, "hello")
if block {
t.Fatal("additionalContext must not block")
}
if cfg.Reminders == nil || cfg.Reminders.Empty() {
t.Fatal("additionalContext should register a reminder")
}
// First turn: the injected context appears.
first := cfg.Reminders.Messages(context.Background(), nil)
if len(first) != 1 {
t.Fatalf("expected 1 reminder message on first turn, got %d", len(first))
}
if txt := textOfUserMsg(first[0]); !strings.Contains(txt, "remember: run gofmt") {
t.Fatalf("injected context missing, got %q", txt)
}
// Second turn: the one-shot provider is silent.
if second := cfg.Reminders.Messages(context.Background(), nil); len(second) != 0 {
t.Fatalf("one-shot reminder must not fire twice, got %d", len(second))
}
}
// TestUserPromptSubmitNilDispatcher: no hooks configured is a no-op.
func TestUserPromptSubmitNilDispatcher(t *testing.T) {
var cfg runtime.RunConfig
block, reason := DispatchUserPromptSubmit(context.Background(), nil, &cfg, HookDeps{}, "hello")
if block || reason != "" {
t.Fatalf("nil dispatcher should be a no-op, got (%v, %q)", block, reason)
}
if cfg.Reminders != nil {
t.Fatal("nil dispatcher must not allocate a registry")
}
}
func textOfUserMsg(m agentcore.AgentMessage) string {
um, ok := m.(agentcore.UserMessage)
if !ok {
return ""
}
var b strings.Builder
for _, c := range um.Content {
if tc, ok := c.(agentcore.TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
+51
View File
@@ -0,0 +1,51 @@
// This file provides the cli-layer helper that runs the SessionStart hook event
// (US-010, FR-1) synchronously at the run-start seam. It lives in the cli layer
// alongside the other hook assembly (hooks_install.go) because it bridges the
// resolved Dispatcher and the runtime.RunConfig, which runtime itself must not
// know about.
//
// SessionStart is dispatched SYNCHRONOUSLY at run start rather than through the
// async OnEvent notifier: an async dispatch could land after the first turn's
// request is built, so its additionalContext would miss the first turn (SPEC
// §11.2). Dispatching inline before the loop starts guarantees the injected
// context is present for the very first turn.
//
// Only injection is supported (there is nothing to block at session start): any
// additionalContext is registered as a ONE-SHOT reminder on cfg.Reminders so the
// text is injected into the first turn's provider context via the existing
// TransformContext seam, then never again. It is not written to the persisted
// message history (the reminder mechanism is ephemeral by construction).
package run
import (
"context"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// DispatchSessionStart runs the SessionStart event once at run start. source is
// "startup" for a fresh run or "resume" when continuing an existing session; it
// is carried in the HookInput so hooks can differentiate. Any additionalContext
// returned by the hook is registered as a one-shot reminder on cfg.Reminders
// (allocating a registry when cfg.Reminders is nil) so it is injected into the
// first turn only.
//
// A nil dispatcher (no hooks configured) is a no-op.
func DispatchSessionStart(ctx context.Context, d *hooks.Dispatcher, cfg *runtime.RunConfig, deps HookDeps, source string) {
if d == nil {
return
}
dec := d.Dispatch(ctx, "SessionStart", "", hooks.HookInput{
EventType: "SessionStart",
SessionID: deps.SessionID,
ProjectDir: deps.ProjectDir,
Source: source,
})
if dec.AdditionalContext != "" && cfg != nil {
if cfg.Reminders == nil {
cfg.Reminders = runtime.NewReminderRegistry()
}
cfg.Reminders.Register(runtime.NewOneShotReminder("session-start", dec.AdditionalContext))
}
}
@@ -0,0 +1,76 @@
package run
import (
"context"
"strings"
"testing"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// sessionDispatcher builds a Dispatcher for a single SessionStart matcher.
func sessionDispatcher(t *testing.T, cmd string) *hooks.Dispatcher {
t.Helper()
set := hooks.HookSet{
"SessionStart": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: cmd}}}},
}
d := hooks.NewDispatcher(set, t.TempDir(), nil)
if d == nil {
t.Fatal("expected non-nil dispatcher")
}
return d
}
// TestSessionStartInjectsOnce: a SessionStart hook printing additionalContext
// registers a one-shot reminder that fires on the first turn, then goes silent.
func TestSessionStartInjectsOnce(t *testing.T) {
d := sessionDispatcher(t, `echo '{"additionalContext":"project context loaded"}'`)
var cfg runtime.RunConfig
DispatchSessionStart(context.Background(), d, &cfg, HookDeps{SessionID: "s1"}, "startup")
if cfg.Reminders == nil || cfg.Reminders.Empty() {
t.Fatal("SessionStart additionalContext should register a reminder")
}
first := cfg.Reminders.Messages(context.Background(), nil)
if len(first) != 1 {
t.Fatalf("expected 1 reminder message on the first turn, got %d", len(first))
}
if txt := textOfUserMsg(first[0]); !strings.Contains(txt, "project context loaded") {
t.Fatalf("injected context missing, got %q", txt)
}
if second := cfg.Reminders.Messages(context.Background(), nil); len(second) != 0 {
t.Fatalf("one-shot reminder must not fire twice, got %d", len(second))
}
}
// TestSessionStartResumeSource: the source ("resume") is threaded into the hook
// input so the hook can differentiate startup from resume. The hook echoes its
// stdin JSON's source field into additionalContext, which we then observe.
func TestSessionStartResumeSource(t *testing.T) {
d := sessionDispatcher(t, `in=$(cat); case "$in" in *'"source":"resume"'*) echo '{"additionalContext":"source=resume"}';; *) echo '{}';; esac`)
var cfg runtime.RunConfig
DispatchSessionStart(context.Background(), d, &cfg, HookDeps{SessionID: "s1"}, "resume")
if cfg.Reminders == nil || cfg.Reminders.Empty() {
t.Fatal("expected a reminder registered")
}
msgs := cfg.Reminders.Messages(context.Background(), nil)
if len(msgs) != 1 {
t.Fatalf("expected 1 reminder message, got %d", len(msgs))
}
if txt := textOfUserMsg(msgs[0]); !strings.Contains(txt, "source=resume") {
t.Fatalf("resume source not threaded into hook input, got %q", txt)
}
}
// TestSessionStartNilDispatcher: no hooks configured is a no-op.
func TestSessionStartNilDispatcher(t *testing.T) {
var cfg runtime.RunConfig
DispatchSessionStart(context.Background(), nil, &cfg, HookDeps{}, "startup")
if cfg.Reminders != nil {
t.Fatal("nil dispatcher must not allocate a registry")
}
}
+105
View File
@@ -0,0 +1,105 @@
// This file provides the cli-layer decorator that runs the Stop and SubagentStop
// hook events (US-008/009, FR-10/12) at the loop's natural-end seam
// (runtime.RunConfig.OnStop). It lives in the cli layer because it bridges the
// resolved Dispatcher and runtime, which must not depend on hooks.
//
// A Stop hook may block the run from ending and force a continuation, feeding
// its reason back as guidance. Left unchecked a hook that always blocks would
// loop forever, so the decorator holds a per-run consecutive-block counter and
// force-stops after maxConsecutiveStopBlocks (FR-12). The counter resets on any
// natural (non-blocking) stop, so an occasional block does not erode the budget.
package run
import (
"context"
"fmt"
"os"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// maxConsecutiveStopBlocks is the default FR-12 ceiling on how many times a Stop
// (or SubagentStop) hook may consecutively block the run from ending before the
// decorator forces a stop and warns. Overridable via the builder.
const maxConsecutiveStopBlocks = 5
// stopHook builds a runtime.OnStop seam that dispatches the given event
// ("Stop" for a top-level run, "SubagentStop" inside a sub-agent) each time the
// loop is about to end. On a blocking decision it returns a StopDecision that
// keeps the run alive with the hook's reason as guidance, up to maxBlocks
// consecutive blocks; past that it forces a stop and warns. maxBlocks <= 0 uses
// maxConsecutiveStopBlocks. A nil dispatcher yields a nil seam (no wrapping).
func stopHook(d *hooks.Dispatcher, deps HookDeps, event string, maxBlocks int) func(context.Context, *agentcore.AgentContext) *runtime.StopDecision {
if d == nil {
return nil
}
if maxBlocks <= 0 {
maxBlocks = maxConsecutiveStopBlocks
}
warn := deps.WarnLog
if warn == nil {
warn = os.Stderr
}
consecutive := 0
return func(ctx context.Context, _ *agentcore.AgentContext) *runtime.StopDecision {
dec := d.Dispatch(ctx, event, "", hooks.HookInput{
EventType: event,
SessionID: deps.SessionID,
ProjectDir: deps.ProjectDir,
StopReason: "end_turn",
})
if !dec.Block {
consecutive = 0
return nil
}
consecutive++
if consecutive > maxBlocks {
fmt.Fprintf(warn, "%s hook blocked the run %d times consecutively; forcing stop\n", event, consecutive-1)
consecutive = 0
return nil
}
return &runtime.StopDecision{Block: true, Guidance: hookReason(dec.Reason, event)}
}
}
// InstallSubagentStop wires the SubagentStop hook onto a sub-agent's RunConfig,
// so a SubagentStop hook can block a sub-agent from ending (same semantics as
// Stop, evaluated in the sub-agent context, with its own consecutive-block
// budget). The sub-agent assembly calls this on the child runCfg (converged in
// #425). A nil dispatcher is a no-op.
func InstallSubagentStop(cfg *runtime.RunConfig, d *hooks.Dispatcher, deps HookDeps) {
installStopHook(cfg, d, deps, "SubagentStop")
}
// installStopHook wires a Stop-family decorator onto cfg.OnStop, chaining onto
// any existing seam (an earlier OnStop is consulted first and its block wins,
// mirroring the block-short-circuit combinator convention). It is called by the
// tool-execution wiring for the top-level run and by the sub-agent assembly for
// the SubagentStop variant.
func installStopHook(cfg *runtime.RunConfig, d *hooks.Dispatcher, deps HookDeps, event string) {
next := stopHook(d, deps, event, 0)
if next == nil {
return
}
cfg.OnStop = chainOnStop(cfg.OnStop, next)
}
// chainOnStop composes two OnStop seams: prev is consulted first and a blocking
// decision short-circuits (next does not run), so an earlier gate stays
// authoritative. A nil operand is identity.
func chainOnStop(prev, next func(context.Context, *agentcore.AgentContext) *runtime.StopDecision) func(context.Context, *agentcore.AgentContext) *runtime.StopDecision {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ctx context.Context, agentCtx *agentcore.AgentContext) *runtime.StopDecision {
if dec := prev(ctx, agentCtx); dec != nil && dec.Block {
return dec
}
return next(ctx, agentCtx)
}
}
+84
View File
@@ -0,0 +1,84 @@
package run
import (
"context"
"strings"
"testing"
"github.com/smallnest/pigo/internal/hooks"
)
func stopDispatcher(t *testing.T, event, cmd string) *hooks.Dispatcher {
t.Helper()
set := hooks.HookSet{
event: {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: cmd}}}},
}
d := hooks.NewDispatcher(set, t.TempDir(), nil)
if d == nil {
t.Fatal("expected non-nil dispatcher")
}
return d
}
// TestStopHookBlocksThenForceStops: a Stop hook that always blocks (exit 2) is
// honored up to the limit, then the decorator force-stops (returns nil) so the
// run cannot loop forever (FR-12).
func TestStopHookBlocksThenForceStops(t *testing.T) {
d := stopDispatcher(t, "Stop", `echo "not done yet" 1>&2; exit 2`)
var warn strings.Builder
seam := stopHook(d, HookDeps{SessionID: "s1", WarnLog: &warn}, "Stop", 3)
if seam == nil {
t.Fatal("expected non-nil seam")
}
// First 3 consultations block with the reason as guidance.
for i := 0; i < 3; i++ {
dec := seam(context.Background(), nil)
if dec == nil || !dec.Block {
t.Fatalf("consult %d: expected a blocking decision", i+1)
}
if !strings.Contains(dec.Guidance, "not done yet") {
t.Fatalf("consult %d: block reason not surfaced as guidance, got %q", i+1, dec.Guidance)
}
}
// 4th consultation exceeds the limit: force stop (nil) + a warning.
if dec := seam(context.Background(), nil); dec != nil {
t.Fatalf("expected force-stop (nil) past the limit, got %+v", dec)
}
if !strings.Contains(warn.String(), "forcing stop") {
t.Fatalf("force-stop should warn, got %q", warn.String())
}
}
// TestStopHookResetsCounterOnAllow: a non-blocking hook always returns nil, so
// the run is free to end and the consecutive-block counter never accrues.
func TestStopHookResetsCounterOnAllow(t *testing.T) {
d := stopDispatcher(t, "Stop", `exit 0`)
seam := stopHook(d, HookDeps{SessionID: "s1"}, "Stop", 2)
for i := 0; i < 5; i++ {
if dec := seam(context.Background(), nil); dec != nil {
t.Fatalf("consult %d: a non-blocking hook must let the run end (nil), got %+v", i+1, dec)
}
}
}
// TestStopHookNilDispatcher: no hooks configured yields a nil seam (no wrapping).
func TestStopHookNilDispatcher(t *testing.T) {
if seam := stopHook(nil, HookDeps{}, "Stop", 0); seam != nil {
t.Fatal("nil dispatcher must yield a nil seam")
}
}
// TestSubagentStopEvent: InstallSubagentStop wires a SubagentStop decorator whose
// block keeps a sub-agent running with the hook's reason.
func TestSubagentStopEvent(t *testing.T) {
d := stopDispatcher(t, "SubagentStop", `echo "sub not done" 1>&2; exit 2`)
seam := stopHook(d, HookDeps{SessionID: "child"}, "SubagentStop", 5)
if seam == nil {
t.Fatal("expected non-nil SubagentStop seam")
}
dec := seam(context.Background(), nil)
if dec == nil || !dec.Block || !strings.Contains(dec.Guidance, "sub not done") {
t.Fatalf("SubagentStop block not honored, got %+v", dec)
}
}
+189
View File
@@ -0,0 +1,189 @@
package run
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// recordingTool is a fake AgentTool that records whether Execute ran and echoes
// a fixed result, so a PreToolUse block can be asserted as "never executed".
type recordingTool struct {
name string
ran *bool
}
func (t recordingTool) Name() string { return t.name }
func (t recordingTool) Description() string { return "fake" }
func (t recordingTool) Schema() json.RawMessage { return nil }
func (t recordingTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
func (t recordingTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
*t.ran = true
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("executed")}}, nil
}
func wiredConfig(t *testing.T, tool agentcore.AgentTool, set hooks.HookSet) runtime.RunConfig {
t.Helper()
reg := agenttool.NewToolRegistry()
if err := reg.Register(tool); err != nil {
t.Fatalf("register: %v", err)
}
var cfg runtime.RunConfig
cfg.Batch.ToolExecutorConfig.Registry = reg
if d := InstallHooks(&cfg, set, HookDeps{SessionID: "s1", ProjectDir: t.TempDir()}); d == nil {
t.Fatal("expected non-nil dispatcher")
}
return cfg
}
// TestPreToolUseBlocksBashRmRf: a PreToolUse hook matching bash inspects the
// piped tool_input for "rm -rf" and exits 2 with a reason; the tool must not run
// and the reason must surface in the result the model receives.
func TestPreToolUseBlocksBashRmRf(t *testing.T) {
ran := false
tool := recordingTool{name: "bash", ran: &ran}
// Hook: block (exit 2) when stdin JSON contains "rm -rf", printing a reason.
cmd := `if grep -q "rm -rf" ; then echo "dangerous command blocked" 1>&2; exit 2; fi`
set := hooks.HookSet{
"PreToolUse": {{Matcher: "bash", Hooks: []hooks.HookConfig{{Command: cmd}}}},
}
cfg := wiredConfig(t, tool, set)
call := agentcore.AgentToolCall{ID: "1", Name: "bash", Arguments: json.RawMessage(`{"command":"rm -rf /tmp/x"}`)}
msgs, _ := agenttool.ExecuteToolCalls(context.Background(), cfg.Batch, []agentcore.AgentToolCall{call}, nil)
if ran {
t.Fatal("tool must not execute when PreToolUse blocks")
}
if len(msgs) != 1 || !msgs[0].IsError {
t.Fatalf("blocked call should be an error result: %+v", msgs)
}
if txt := textOfMsg(msgs[0]); !strings.Contains(txt, "dangerous command blocked") {
t.Fatalf("block reason not surfaced to model, got %q", txt)
}
}
// TestPreToolUseAllowsSafeBash: the same hook allows a command without "rm -rf".
func TestPreToolUseAllowsSafeBash(t *testing.T) {
ran := false
tool := recordingTool{name: "bash", ran: &ran}
cmd := `if grep -q "rm -rf" ; then echo "blocked" 1>&2; exit 2; fi`
set := hooks.HookSet{
"PreToolUse": {{Matcher: "bash", Hooks: []hooks.HookConfig{{Command: cmd}}}},
}
cfg := wiredConfig(t, tool, set)
call := agentcore.AgentToolCall{ID: "1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls"}`)}
msgs, _ := agenttool.ExecuteToolCalls(context.Background(), cfg.Batch, []agentcore.AgentToolCall{call}, nil)
if !ran {
t.Fatal("safe command should execute")
}
if msgs[0].IsError {
t.Fatalf("safe command should not error: %+v", msgs[0])
}
}
// TestPostToolUseAppendsFeedback: a PostToolUse hook prints additionalContext,
// which must be appended to the executed tool's result (not undo it).
func TestPostToolUseAppendsFeedback(t *testing.T) {
ran := false
tool := recordingTool{name: "write", ran: &ran}
cmd := `echo '{"additionalContext":"linted: 0 issues"}'`
set := hooks.HookSet{
"PostToolUse": {{Matcher: "write", Hooks: []hooks.HookConfig{{Command: cmd}}}},
}
cfg := wiredConfig(t, tool, set)
call := agentcore.AgentToolCall{ID: "1", Name: "write", Arguments: json.RawMessage(`{"path":"a.go"}`)}
msgs, _ := agenttool.ExecuteToolCalls(context.Background(), cfg.Batch, []agentcore.AgentToolCall{call}, nil)
if !ran {
t.Fatal("tool should execute; Post hook must not undo it")
}
txt := allTextOfMsg(msgs[0])
if !strings.Contains(txt, "executed") {
t.Fatalf("original result lost: %q", txt)
}
if !strings.Contains(txt, "linted: 0 issues") {
t.Fatalf("Post hook feedback not appended: %q", txt)
}
}
// TestPreToolUseUpdatedInputRewritesArgs: a PreToolUse hook returns updatedInput,
// which must replace the tool's arguments before execution.
func TestPreToolUseUpdatedInputRewritesArgs(t *testing.T) {
var gotArgs json.RawMessage
captured := false
tool := capturingTool{name: "bash", got: &gotArgs, captured: &captured}
reg := agenttool.NewToolRegistry()
if err := reg.Register(tool); err != nil {
t.Fatalf("register: %v", err)
}
cmd := `echo '{"updatedInput":{"command":"echo safe"}}'`
set := hooks.HookSet{
"PreToolUse": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: cmd}}}},
}
var cfg runtime.RunConfig
cfg.Batch.ToolExecutorConfig.Registry = reg
if d := InstallHooks(&cfg, set, HookDeps{ProjectDir: t.TempDir()}); d == nil {
t.Fatal("expected non-nil dispatcher")
}
call := agentcore.AgentToolCall{ID: "1", Name: "bash", Arguments: json.RawMessage(`{"command":"rm -rf /"}`)}
agenttool.ExecuteToolCalls(context.Background(), cfg.Batch, []agentcore.AgentToolCall{call}, nil)
if !captured {
t.Fatal("tool should have executed with rewritten args")
}
if !strings.Contains(string(gotArgs), "echo safe") {
t.Fatalf("args not rewritten by updatedInput, got %q", string(gotArgs))
}
}
type capturingTool struct {
name string
got *json.RawMessage
captured *bool
}
func (t capturingTool) Name() string { return t.name }
func (t capturingTool) Description() string { return "capture" }
func (t capturingTool) Schema() json.RawMessage { return nil }
func (t capturingTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
func (t capturingTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
*t.got = args
*t.captured = true
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("ok")}}, nil
}
func textOfMsg(msg agentcore.ToolResultMessage) string {
if len(msg.Content) == 0 {
return ""
}
if tc, ok := msg.Content[0].(agentcore.TextContent); ok {
return tc.Text
}
return ""
}
func allTextOfMsg(msg agentcore.ToolResultMessage) string {
var b strings.Builder
for _, c := range msg.Content {
if tc, ok := c.(agentcore.TextContent); ok {
b.WriteString(tc.Text)
b.WriteString("\n")
}
}
return b.String()
}
@@ -0,0 +1,87 @@
package run
// Tests for the persistent-memory loop wiring (#481): OpenMemoryStore's
// enabled/disabled contract, MemoryRootFromTools resolving through the opened
// store, and TodoReminders registering the memory reminder provider alongside
// the todo one.
import (
"path/filepath"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/memory"
)
// TestOpenMemoryStoreDisabled verifies memory.enabled=false yields (nil, nil):
// a disabled store is not an error, so the caller degrades to file-based
// auto-memory without logging a failure.
func TestOpenMemoryStoreDisabled(t *testing.T) {
store, err := OpenMemoryStore(false)
if err != nil {
t.Fatalf("OpenMemoryStore(false) err = %v, want nil", err)
}
if store != nil {
t.Fatalf("OpenMemoryStore(false) store = %v, want nil", store)
}
}
// TestMemoryDirHonorsPIGOHome verifies MemoryDir roots the store at
// $PIGO_HOME/memory when the override is set.
func TestMemoryDirHonorsPIGOHome(t *testing.T) {
dir := t.TempDir()
t.Setenv("PIGO_HOME", dir)
if got, want := MemoryDir(), filepath.Join(dir, "memory"); got != want {
t.Errorf("MemoryDir() = %q, want %q", got, want)
}
}
// TestMemoryRootFromToolsPresent verifies the root is resolved through the
// memory_search tool's Store.Root() when one is wired into the tool set.
func TestMemoryRootFromToolsPresent(t *testing.T) {
root := t.TempDir()
store, err := memory.Open(filepath.Join(root, "index.db"), root, "")
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
defer store.Close()
tools := []agentcore.AgentTool{&agenttool.MemorySearchTool{Store: store}}
if got := MemoryRootFromTools(tools); got != root {
t.Errorf("MemoryRootFromTools = %q, want %q", got, root)
}
}
// TestMemoryRootFromToolsAbsent verifies the root is "" when no memory_search
// tool (or a store-less one) is present.
func TestMemoryRootFromToolsAbsent(t *testing.T) {
if got := MemoryRootFromTools(nil); got != "" {
t.Errorf("MemoryRootFromTools(nil) = %q, want empty", got)
}
tools := []agentcore.AgentTool{&agenttool.MemorySearchTool{Store: nil}}
if got := MemoryRootFromTools(tools); got != "" {
t.Errorf("MemoryRootFromTools(store-less) = %q, want empty", got)
}
}
// TestTodoRemindersRegistersMemoryProvider verifies TodoReminders builds a
// non-empty registry from a memory_search tool alone, and stays nil when no
// provider-bearing tool is present.
func TestTodoRemindersRegistersMemoryProvider(t *testing.T) {
root := t.TempDir()
store, err := memory.Open(filepath.Join(root, "index.db"), root, "")
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
defer store.Close()
reg := TodoReminders([]agentcore.AgentTool{&agenttool.MemorySearchTool{Store: store}})
if reg == nil || reg.Empty() {
t.Fatal("TodoReminders with a memory_search tool should yield a non-empty registry")
}
if reg := TodoReminders(nil); reg != nil {
t.Errorf("TodoReminders(nil) = %v, want nil", reg)
}
}
@@ -0,0 +1,88 @@
package run
// Tests for --append-system-prompt value resolution (mirrors pi): each value is
// either a path to an existing file whose contents are appended, or literal
// text when it is not an existing file. A value that names an unreadable file
// (a real I/O error other than not-exist) is surfaced rather than silently
// appended verbatim.
import (
"os"
"path/filepath"
"testing"
)
// TestResolveAppendInstructionsEmpty verifies no values yields no appends.
func TestResolveAppendInstructionsEmpty(t *testing.T) {
out, err := resolveAppendInstructions(nil)
if err != nil {
t.Fatalf("resolveAppendInstructions: %v", err)
}
if out != nil {
t.Errorf("expected nil for no values, got %#v", out)
}
}
// TestResolveAppendInstructionsLiteral verifies a value that is not an existing
// file is treated as literal text and passed through verbatim.
func TestResolveAppendInstructionsLiteral(t *testing.T) {
out, err := resolveAppendInstructions([]string{"be concise and helpful"})
if err != nil {
t.Fatalf("resolveAppendInstructions: %v", err)
}
if len(out) != 1 || out[0] != "be concise and helpful" {
t.Errorf("literal text must pass through verbatim, got %#v", out)
}
}
// TestResolveAppendInstructionsFile verifies a value that names an existing
// file has its contents read and appended.
func TestResolveAppendInstructionsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "guidance.txt")
if err := os.WriteFile(path, []byte("FILE GUIDANCE"), 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
out, err := resolveAppendInstructions([]string{path, "literal tail"})
if err != nil {
t.Fatalf("resolveAppendInstructions: %v", err)
}
if len(out) != 2 {
t.Fatalf("expected 2 resolved values, got %#v", out)
}
if out[0] != "FILE GUIDANCE" {
t.Errorf("existing file must be read into the append, got %q", out[0])
}
if out[1] != "literal tail" {
t.Errorf("literal value must pass through verbatim, got %q", out[1])
}
}
// TestResolveAppendInstructionsUnreadableFile verifies a value that looks like a
// path but points at an unreadable file (here, a directory) surfaces an error
// rather than being appended verbatim. A directory is used because os.Stat
// succeeds on it (so it is not treated as literal text) while os.ReadFile fails.
func TestResolveAppendInstructionsUnreadableFile(t *testing.T) {
dir := t.TempDir()
// A directory: Stat succeeds and IsDir() is true, so it is treated as literal
// text — assert that. Then create an actually-unreadable regular file to hit
// the read-error path.
if out, err := resolveAppendInstructions([]string{dir}); err != nil {
t.Fatalf("a directory should be treated as literal text, got error: %v", err)
} else if len(out) != 1 || out[0] != dir {
t.Errorf("a directory path must pass through as literal text, got %#v", out)
}
unreadable := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(unreadable, []byte("nope"), 0o000); err != nil {
t.Fatalf("write temp file: %v", err)
}
// Root can read 0o000 files, so skip the read-error assertion when running as
// root (common in CI containers) — the path would succeed instead of erroring.
if os.Geteuid() == 0 {
t.Skip("running as root: 0o000 file is still readable, cannot exercise read-error path")
}
if _, err := resolveAppendInstructions([]string{unreadable}); err == nil {
t.Error("an unreadable append file must surface an error, got nil")
}
}
+604
View File
@@ -0,0 +1,604 @@
// Package run holds the run-assembly layer (US-005, #362): the shared setup that
// both the interactive REPL and the headless driver need — resolving the
// provider, building the tool set rooted at the working directory, discovering
// skills and plugins, and constructing the loop RunConfig. Pulling it out of
// cmd/pigo lets the subpackages assemble a run through one exported API instead
// of duplicating the wiring.
package run
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/builtinskills"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/memory"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/trust"
)
// Env is the environment every run shares: the working directory, the tool set
// rooted at it, the resolved provider, and the system prompt. It is assembled
// once (SetupEnv) and consumed by whichever driver runs.
type Env struct {
Cwd string
Tools []agentcore.AgentTool
Provider provider.Provider
ProviderName string
SysPrompt string
// Skills is the discovered skill set (loaded once here, empty under
// --no-skills). It is threaded into the REPL so each skill is registered as a
// /skill-name command, and the model-invocable subset is already injected into
// SysPrompt.
Skills []*runtime.Skill
// Plugins holds any loaded external plugins so the caller can Close them when
// the run ends. It is nil when no plugins were discovered.
Plugins *plugin.Manager
// Memory is the persistent memory store opened once for the run (issue #481),
// or nil when persistent memory is disabled (memory.enabled=false), tools are
// disabled (--no-tools), or the store could not be opened (a non-fatal
// failure). When non-nil the caller MUST Close it when the run ends. The store
// is also handed to the memory_search tool (in Tools) and, through it, the
// per-turn memory reminder provider, so this field exists mainly so the owner
// can close the DB — downstream wiring reaches the store via the tool.
Memory *memory.Store
}
// SetupEnv resolves the provider for model/baseURL, builds the tool set rooted
// at the working directory, and constructs the system prompt — the setup the
// REPL and headless drivers both need. systemPrompt, when non-empty, replaces
// the default base instruction (mirrors pi's --system-prompt); appendSystemPrompt
// entries are each resolved (a path to an existing file is read, otherwise the
// value is literal text) and layered onto the end of the prompt (mirrors pi's
// --append-system-prompt). apiKey is the resolved credential (CLI --api-key or
// config.toml) used as the override for sub-agent credential resolution so
// dispatched task children authenticate the same way the parent does. policy is
// the --allowed-tools/--disallowed-tools boundary; it is validated against the
// fully assembled tool set and then applied, so an unknown tool name is a usage
// error rather than a silently ineffective boundary. It returns an error rather
// than exiting so the caller owns exit-code mapping.
func SetupEnv(model, baseURL, protocol, providerName, apiKey string, noTools, noSkills bool, systemPrompt string, appendSystemPrompt []string, memEnabled bool, policy ToolPolicy) (Env, error) {
cwd, _ := os.Getwd()
prov, resolvedName, err := provider.ResolveProvider(model, baseURL, protocol, providerName, os.Getenv)
if err != nil {
return Env{}, err
}
appends, err := resolveAppendInstructions(appendSystemPrompt)
if err != nil {
return Env{}, err
}
tools := BuiltinTools(cwd, noTools)
// Open the persistent memory store once (issue #481) and expose it as the
// memory_search tool so the agent can recall earlier context. Memory is a
// tool, so it is skipped under --no-tools; memory.enabled=false disables it
// too. Opening is non-fatal: a failure logs and leaves memory off, matching
// the "fall back to file-based auto-memory" contract.
var memStore *memory.Store
if !noTools {
if store, err := OpenMemoryStore(memEnabled); err != nil {
fmt.Fprintf(os.Stderr, "pigo: memory disabled: %v\n", err)
} else if store != nil {
memStore = store
tools = append(tools, &agenttool.MemorySearchTool{Store: store})
}
}
// Wire the generic task tool (US-002, #454) unless tools are disabled. It
// dispatches general-purpose sub-agents that reuse the resolved provider
// stream/model. Each spawn gets a fresh child RunConfig whose registry is the
// builtins with "task" removed (the nesting guard, so a child cannot fan out
// again), and all task calls in a run share one semaphore capping concurrency.
if !noTools {
sem := runtime.NewSubagentSemaphore()
// The child resolves credentials the same way the parent does: env/OAuth via
// a fresh store, plus the CLI/config api key as an override. Without the
// override a child would get an empty key whenever auth comes from config.toml
// or --api-key (not an env var), leaving every sub-agent unauthenticated.
childCreds := provider.NewCredentialStore(nil)
childCreds.SetOverride(resolvedName, apiKey)
factory := func() runtime.RunConfig {
childTools := ChildToolSet(cwd, policy)
return runtime.RunConfig{
LoopConfig: runtime.LoopConfig{
Model: model,
Provider: resolvedName,
Stream: provider.StreamFnFromProvider(prov),
GetAPIKey: childCreds.GetAPIKey,
},
Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: ToolRegistry(childTools)}},
}
}
tools = append(tools, runtime.NewTaskTool(factory, sem))
}
// Wire the blackboard tool (coop/): present only when the BB environment
// variable names a blackboard root. It is the atomic shared-file primitive
// of the pigo coop runner (task.md / workspace / DONE); without BB it is
// absent so ordinary runs are unaffected. Like memory, it is a tool, so
// --no-tools disables it.
if !noTools {
if bb := strings.TrimSpace(os.Getenv("BB")); bb != "" {
tools = append(tools, &agenttool.BlackboardTool{Root: bb})
}
}
// Discover external plugins (US-016) and append their tools. Plugin loading
// is fault-tolerant: a plugin that fails to start is logged and skipped, and
// disabling tools (--no-tools) skips plugin discovery entirely.
var mgr *plugin.Manager
if !noTools {
if m, err := plugin.Discover(PluginsDir(), os.Stderr, os.Stderr); err == nil {
tools = append(tools, m.Tools()...)
mgr = m
} else {
fmt.Fprintf(os.Stderr, "pigo: plugin discovery failed: %v\n", err)
}
}
// Enforce the --allowed-tools/--disallowed-tools boundary now that the set is
// complete. Validation must happen here rather than at flag-parse time: plugin
// and memory tool names only exist at runtime, so an earlier check would reject
// legitimate names. Filtering here — at the registration layer, before the
// BeforeToolCall confirmation gate — is what makes the boundary structural: a
// removed tool is never advertised and never dispatchable, so --approve cannot
// widen it.
if err := ValidateToolPolicy(tools, policy); err != nil {
return Env{}, err
}
tools = ApplyToolPolicy(tools, policy)
if len(tools) == 0 && !noTools && !policy.IsZero() {
fmt.Fprintln(os.Stderr, "pigo: warning: the tool policy removed every tool; the model will run without tools")
}
// --no-tools already disables everything, so a tool policy alongside it has
// no effect — and because the set is empty, ValidateToolPolicy above skipped
// name validation, meaning a typo here would otherwise pass unnoticed. Say so
// rather than letting the user believe a boundary is in force.
if noTools && !policy.IsZero() {
fmt.Fprintln(os.Stderr, "pigo: warning: --no-tools disables all tools; --allowed-tools/--disallowed-tools are ignored (and unvalidated)")
}
// Load skills once (shared between prompt injection and /skill-name
// registration). A partial parse error still yields the skills that DID load,
// so one malformed file is a non-fatal warning rather than a hard failure.
skills, err := LoadSkills(noSkills)
if err != nil {
fmt.Fprintf(os.Stderr, "pigo: skills: %v\n", err)
}
// The model can only load a skill's body when the read tool is present, so
// advertise skills in the prompt only then (mirrors pi's selectedTools check).
sysPrompt, err := runtime.BuildSystemPrompt(runtime.PromptConfig{
BaseInstruction: systemPrompt,
WorkingDir: cwd,
Root: cwd,
AppendInstructions: appends,
Skills: skills,
ReadToolAvailable: hasReadTool(tools),
})
if err != nil {
return Env{}, err
}
return Env{
Cwd: cwd,
Tools: tools,
Provider: prov,
ProviderName: resolvedName,
SysPrompt: sysPrompt,
Skills: skills,
Plugins: mgr,
Memory: memStore,
}, nil
}
// hasReadTool reports whether the read tool is present in the tool set. Skills
// are advertised in the system prompt only when it is, since the model needs the
// read tool to load a skill's body on demand.
func hasReadTool(tools []agentcore.AgentTool) bool {
for _, t := range tools {
if t.Name() == "read" {
return true
}
}
return false
}
// resolveAppendInstructions maps each --append-system-prompt value to the text
// to append. Following pi, a value that names an existing regular file is read
// and its contents are appended; any other value (a non-existent path, or a
// directory) is treated as literal text. Only a value that stats as a regular
// file but then fails to read (e.g. a permission error) is reported, so a
// genuinely broken file path is not silently appended verbatim.
func resolveAppendInstructions(values []string) ([]string, error) {
if len(values) == 0 {
return nil, nil
}
out := make([]string, 0, len(values))
for _, v := range values {
info, statErr := os.Stat(v)
if statErr == nil && !info.IsDir() {
data, err := os.ReadFile(v)
if err != nil {
return nil, fmt.Errorf("read --append-system-prompt file %q: %w", v, err)
}
out = append(out, string(data))
continue
}
out = append(out, v)
}
return out, nil
}
// BuiltinTools returns the default file/shell tool set rooted at cwd, or nil
// when tools are disabled. The todo tool is stateful: a single TodoStore is
// created here and held by the one TodoTool instance, so the task list persists
// across calls within a run (a later write replaces the plan).
func BuiltinTools(cwd string, disabled bool) []agentcore.AgentTool {
if disabled {
return nil
}
// A single recorder is shared by the write and edit tools so /rewind can roll
// back every mutation from a turn regardless of which tool made it.
snap := agenttool.NewFileSnapshotRecorder()
// A single job store is shared by bash, bash_output and kill_bash so a
// background command launched by bash is visible to the drain/kill tools.
jobs := agenttool.NewBashJobStore()
return []agentcore.AgentTool{
&agenttool.ReadTool{Root: cwd, ExtraRoots: ReadableExtraRoots()},
&agenttool.WriteTool{Root: cwd, ExtraRoots: ReadableExtraRoots(), Snap: snap},
&agenttool.EditTool{Root: cwd, ExtraRoots: ReadableExtraRoots(), Snap: snap},
&agenttool.GrepTool{Root: cwd},
&agenttool.FindTool{Root: cwd},
&agenttool.BashTool{Dir: cwd, Jobs: jobs},
&agenttool.BashOutputTool{Jobs: jobs},
&agenttool.BashKillTool{Jobs: jobs},
&agenttool.TodoTool{Store: agenttool.NewTodoStore()},
&agenttool.WebFetchTool{},
&agenttool.WebSearchTool{},
}
}
// BuiltinToolsExcept returns the default builtin tool set (BuiltinTools) with
// any tool whose name matches one of the except names removed. It backs the
// nesting guard for the generic task tool: a child sub-agent's registry is built
// with "task" excluded so a child can never spawn further sub-agents, capping
// delegation depth at one. With no except names it is equivalent to BuiltinTools.
func BuiltinToolsExcept(cwd string, disabled bool, except ...string) []agentcore.AgentTool {
all := BuiltinTools(cwd, disabled)
if len(except) == 0 || len(all) == 0 {
return all
}
skip := make(map[string]struct{}, len(except))
for _, n := range except {
skip[n] = struct{}{}
}
out := make([]agentcore.AgentTool, 0, len(all))
for _, t := range all {
if _, ok := skip[t.Name()]; ok {
continue
}
out = append(out, t)
}
return out
}
// ReadableExtraRoots returns trusted directories the file tools may reach beyond
// the workspace root. The skills directory is included so the model can load the
// absolute SKILL.md paths pigo advertises in the system prompt, and author or
// update skills there (they otherwise resolve outside the workspace and are
// rejected). An empty skills dir is dropped, so this stays a no-op when the home
// directory cannot be resolved.
func ReadableExtraRoots() []string {
if dir := SkillsDir(); dir != "" {
return []string{dir}
}
return nil
}
// ToolRegistry builds a registry from the given tools (skipping any that fail to
// register, e.g. a bad schema, which should not happen for built-ins).
func ToolRegistry(tools []agentcore.AgentTool) *agenttool.ToolRegistry {
reg := agenttool.NewToolRegistry()
for _, t := range tools {
_ = reg.Register(t)
}
return reg
}
// TodoReminders builds the per-turn system-reminder registry for a tool set
// (US-002): it locates the stateful TodoTool and registers a TodoReminderProvider
// over its shared store, so the model is reminded of unfinished tasks each turn.
// It also registers a MemoryReminderProvider over the memory_search tool's store
// (issue #481) when present, so relevant persisted memory is recalled each turn
// (this is the recall channel used after auto-compaction/rebuild). Returns nil
// when neither provider applies (e.g. --no-tools), leaving injection disabled.
func TodoReminders(tools []agentcore.AgentTool) *runtime.ReminderRegistry {
var providers []runtime.ReminderProvider
for _, t := range tools {
switch tool := t.(type) {
case *agenttool.TodoTool:
if tool.Store != nil {
providers = append(providers, &runtime.TodoReminderProvider{Store: tool.Store})
}
case *agenttool.MemorySearchTool:
if tool.Store != nil {
providers = append(providers, &runtime.MemoryReminderProvider{Store: tool.Store})
}
}
}
if len(providers) == 0 {
return nil
}
return runtime.NewReminderRegistry(providers...)
}
// MemoryRootFromTools returns the persistent memory root the run's memory_search
// tool is backed by (its Store.Root()), or "" when persistent memory is not wired
// into this tool set (memory.enabled=false, --no-tools, or the store failed to
// open). It is the canonical source of the memory root for checkpoint persistence
// and context rebuild (<root>/sessions/<id>/checkpoint.md): callers resolve the
// root through the opened store rather than re-deriving it from the session store.
func MemoryRootFromTools(tools []agentcore.AgentTool) string {
for _, t := range tools {
if mt, ok := t.(*agenttool.MemorySearchTool); ok && mt.Store != nil {
return mt.Store.Root()
}
}
return ""
}
// MemoryStoreFromTools returns the persistent memory Store backing the run's
// memory_search tool, or nil when persistent memory is not wired into this tool
// set. It lets status commands (/memory) inspect the live store without
// re-opening the database.
func MemoryStoreFromTools(tools []agentcore.AgentTool) *memory.Store {
for _, t := range tools {
if mt, ok := t.(*agenttool.MemorySearchTool); ok && mt.Store != nil {
return mt.Store
}
}
return nil
}
// SnapshotRecorderFromTools returns the shared FileSnapshotRecorder backing the
// run's write/edit tools, or nil when file tools are disabled (--no-tools). The
// REPL uses it to commit a per-turn restore point and to serve /rewind.
func SnapshotRecorderFromTools(tools []agentcore.AgentTool) *agenttool.FileSnapshotRecorder {
for _, t := range tools {
switch tool := t.(type) {
case *agenttool.WriteTool:
if tool.Snap != nil {
return tool.Snap
}
case *agenttool.EditTool:
if tool.Snap != nil {
return tool.Snap
}
}
}
return nil
}
// BashJobStoreFromTools returns the shared BashJobStore backing the run's bash /
// bash_output / kill_bash tools, or nil when the shell tool is disabled. The
// REPL uses it to kill any still-running background jobs on exit so they are not
// orphaned.
func BashJobStoreFromTools(tools []agentcore.AgentTool) *agenttool.BashJobStore {
for _, t := range tools {
if bt, ok := t.(*agenttool.BashTool); ok && bt.Jobs != nil {
return bt.Jobs
}
}
return nil
}
// MemoryDir returns the persistent memory root directory: $PIGO_HOME/memory, or
// ~/.pigo/memory by default (a single global store so cross-project "global"
// memories are searchable, mirroring the session store's ~/.pigo base). It
// returns "" when the home directory cannot be resolved and no override is set.
func MemoryDir() string {
dir := os.Getenv("PIGO_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dir = filepath.Join(home, ".pigo")
}
return filepath.Join(dir, "memory")
}
// OpenMemoryStore opens the persistent memory store under MemoryDir() (index DB
// at <root>/index.db). It returns (nil, nil) — not an error — when persistent
// memory is disabled (memEnabled=false) or the home dir is unresolvable, so the
// caller degrades to file-based auto-memory without treating the off state as a
// failure. A genuine open failure is returned as an error for the caller to log
// non-fatally.
func OpenMemoryStore(memEnabled bool) (*memory.Store, error) {
if !memEnabled {
return nil, nil
}
root := MemoryDir()
if root == "" {
return nil, nil
}
dbPath := filepath.Join(root, "index.db")
return memory.Open(dbPath, root, "")
}
// SkillsDir returns the directory skills are loaded from. It defaults to
// ~/.agents/skills, overridable via PIGO_SKILLS_DIR. An empty string is returned
// when the home directory cannot be resolved and no override is set.
func SkillsDir() string {
if dir := os.Getenv("PIGO_SKILLS_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".agents", "skills")
}
// LoadSkills discovers skills from SkillsDir() once, for both prompt injection
// and /skill-name registration. Under --no-skills it is a no-op. Built-in skills
// are bootstrapped into the skills dir first, then the directory is loaded.
func LoadSkills(noSkills bool) ([]*runtime.Skill, error) {
if noSkills {
return nil, nil
}
var blog io.Writer
if os.Getenv("PIGO_DEBUG") != "" {
blog = os.Stderr
}
builtinskills.Bootstrap(ConfigDir(), SkillsDir(), blog)
dir := SkillsDir()
if dir == "" {
return nil, nil
}
return runtime.LoadSkillsDir(dir)
}
// PluginsDir returns the directory external plugins are discovered from:
// $PIGO_HOME/plugins, or ~/.pigo/plugins by default. An empty string is returned
// when the home directory cannot be resolved and no override is set (Discover
// then treats it as "no plugins").
func PluginsDir() string {
dir := os.Getenv("PIGO_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dir = filepath.Join(home, ".pigo")
}
return filepath.Join(dir, "plugins")
}
// ConfigDir returns the directory pigo reads its global config layer from:
// $PIGO_HOME, or ~/.pigo by default. An empty string is returned when the home
// directory cannot be resolved and no override is set (the caller then treats
// the global layer as absent).
func ConfigDir() string {
dir := os.Getenv("PIGO_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dir = filepath.Join(home, ".pigo")
}
return dir
}
// ResolveThinkingLevel resolves the effective reasoning-effort level through the
// layered config chain (US-023): default < global < project < env < CLI flag.
// The global layer is $PIGO_HOME/config.json (or ~/.pigo/config.json); the
// project layer is ./.pigo/config.json in the working directory. A malformed
// layer file or an invalid resolved value is a hard error, surfaced to the
// caller for exit-code mapping. cliLevel is the raw --thinking-level flag ("" =
// unset, so lower layers show through).
func ResolveThinkingLevel(cliLevel string) (agentcore.ThinkingLevel, error) {
def := runtime.DefaultConfigLayer()
layers := []*runtime.ConfigLayer{&def}
if dir := ConfigDir(); dir != "" {
global, err := runtime.LoadConfigLayer(filepath.Join(dir, "config.json"))
if err != nil {
return "", err
}
layers = append(layers, global)
}
project, err := runtime.LoadConfigLayer(filepath.Join(".pigo", "config.json"))
if err != nil {
return "", err
}
layers = append(layers, project)
env := runtime.EnvConfigLayer(os.Getenv)
layers = append(layers, &env)
if v := strings.TrimSpace(cliLevel); v != "" {
cli := runtime.ConfigLayer{ThinkingLevel: &v}
layers = append(layers, &cli)
}
cfg, err := runtime.ResolveConfig(layers...)
if err != nil {
return "", err
}
return cfg.ThinkingLevel, nil
}
// ResolveHookSet resolves the effective hook set through the same layered config
// chain as ResolveThinkingLevel (default < global < project < env), with one
// difference required by FR-14: the project layer (./.pigo/config.json under
// cwd) is only merged when the directory is trusted. An untrusted directory
// therefore contributes no hooks, so a checked-out repo cannot run arbitrary
// commands until the user trusts it. A malformed layer file is a hard error,
// surfaced to the caller. The returned set is empty (len 0) when no layer
// defines hooks, which InstallHooks treats as "no hooks" (FR-18).
func ResolveHookSet(cwd string, trusted bool) (hooks.HookSet, error) {
def := runtime.DefaultConfigLayer()
layers := []*runtime.ConfigLayer{&def}
if dir := ConfigDir(); dir != "" {
global, err := runtime.LoadConfigLayer(filepath.Join(dir, "config.json"))
if err != nil {
return nil, err
}
layers = append(layers, global)
}
if trusted {
project, err := runtime.LoadConfigLayer(filepath.Join(cwd, ".pigo", "config.json"))
if err != nil {
return nil, err
}
layers = append(layers, project)
}
env := runtime.EnvConfigLayer(os.Getenv)
layers = append(layers, &env)
cfg, err := runtime.ResolveConfig(layers...)
if err != nil {
return nil, err
}
return cfg.Hooks, nil
}
// Trusted reports whether cwd is a trusted directory per the shared trust store
// ($PIGO_HOME/trust.json). It is the trust gate for the non-interactive drivers
// (headless / TUI / sub-agent) that have no live trust.Manager to consult, so
// ResolveHookSet can honor FR-14 uniformly. A missing or unreadable store is
// treated as untrusted (fail closed): a directory only runs project-layer hooks
// after the user has explicitly trusted it.
func Trusted(cwd string) bool {
m, err := trust.NewManager(trust.DefaultPath())
if err != nil || m == nil {
return false
}
return m.IsTrusted(cwd)
}
// NewConfig builds the loop configuration shared by every driver: the provider
// stream, the dynamic API-key resolver, and the tool registry. It is the single
// definition of "how a run is wired", so the REPL (streamRun) and the headless
// driver cannot drift apart.
func NewConfig(model, providerName string, thinking agentcore.ThinkingLevel, prov provider.Provider, creds *provider.CredentialStore, reg *agenttool.ToolRegistry, reminders *runtime.ReminderRegistry) runtime.RunConfig {
return runtime.RunConfig{
LoopConfig: runtime.LoopConfig{
Model: model,
Provider: providerName,
ThinkingLevel: thinking,
Stream: provider.StreamFnFromProvider(prov),
GetAPIKey: creds.GetAPIKey,
},
Batch: agenttool.BatchConfig{
ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg},
},
Reminders: reminders,
}
}
+39
View File
@@ -0,0 +1,39 @@
package run
// Tests for the generic task tool wiring (US-004, #454): the nesting guard.
// BuiltinToolsExcept backs the child sub-agent registry, from which "task" is
// removed so a child cannot spawn further sub-agents.
import "testing"
// TestBuiltinToolsExceptExcludesTask verifies the child tool set produced for a
// task sub-agent (builtins minus "task") never contains "task", and that
// excluding a name actually present removes it.
func TestBuiltinToolsExceptExcludesTask(t *testing.T) {
child := BuiltinToolsExcept("/tmp", false, "task")
if len(child) == 0 {
t.Fatal("expected builtin tools in the child set")
}
for _, tl := range child {
if tl.Name() == "task" {
t.Fatal("child tool set must not contain 'task'")
}
}
// Excluding a name that IS present shrinks the set by exactly that tool.
full := BuiltinToolsExcept("/tmp", false)
dropped := BuiltinToolsExcept("/tmp", false, full[0].Name())
if len(dropped) != len(full)-1 {
t.Errorf("excluding %q: got %d tools, want %d", full[0].Name(), len(dropped), len(full)-1)
}
}
// TestBuiltinToolsExceptNoExcept verifies that with no except names the result
// matches BuiltinTools, and that a disabled tool set stays empty.
func TestBuiltinToolsExceptNoExcept(t *testing.T) {
if got, want := len(BuiltinToolsExcept("/tmp", false)), len(BuiltinTools("/tmp", false)); got != want {
t.Errorf("no-except size = %d, want %d", got, want)
}
if got := BuiltinToolsExcept("/tmp", true, "task"); got != nil {
t.Errorf("disabled tools should be nil, got %v", got)
}
}
+120
View File
@@ -0,0 +1,120 @@
package run
// Tests for resolveThinkingLevel: the CLI end of the layered config chain
// (US-023). It resolves the effective reasoning-effort level through
// default < global ($PIGO_HOME/config.json) < project (./.pigo/config.json)
// < env (PIGO_THINKING_LEVEL) < --thinking-level flag, and rejects an invalid
// value. Each test isolates PIGO_HOME and the working directory so it never
// reads the developer's real config.
import (
"os"
"path/filepath"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// isolateConfig points PIGO_HOME at a temp dir and chdir's into a temp working
// directory (restored on cleanup), so no real global/project config leaks in.
func isolateConfig(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("PIGO_HOME", home)
t.Setenv("PIGO_THINKING_LEVEL", "")
wd := t.TempDir()
prev, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(wd); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(prev) })
return home
}
// writeConfig writes a config.json layer with the given thinkingLevel at path.
func writeConfig(t *testing.T, path, level string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := `{"thinkingLevel":"` + level + `"}`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
}
// TestResolveThinkingLevelDefault verifies the built-in default (medium) applies
// when no layer sets a level.
func TestResolveThinkingLevelDefault(t *testing.T) {
isolateConfig(t)
got, err := ResolveThinkingLevel("")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != agentcore.ThinkingMedium {
t.Errorf("level = %q, want medium", got)
}
}
// TestResolveThinkingLevelFlagWins verifies the --thinking-level flag overrides
// every lower layer (global, project, and env).
func TestResolveThinkingLevelFlagWins(t *testing.T) {
home := isolateConfig(t)
writeConfig(t, filepath.Join(home, "config.json"), "low")
writeConfig(t, filepath.Join(".pigo", "config.json"), "high")
t.Setenv("PIGO_THINKING_LEVEL", "minimal")
got, err := ResolveThinkingLevel("xhigh")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != agentcore.ThinkingXHigh {
t.Errorf("level = %q, want xhigh (flag wins)", got)
}
}
// TestResolveThinkingLevelEnvOverProject verifies env beats project, and project
// beats global (precedence: global < project < env).
func TestResolveThinkingLevelEnvOverProject(t *testing.T) {
home := isolateConfig(t)
writeConfig(t, filepath.Join(home, "config.json"), "low")
writeConfig(t, filepath.Join(".pigo", "config.json"), "high")
t.Setenv("PIGO_THINKING_LEVEL", "off")
got, err := ResolveThinkingLevel("")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != agentcore.ThinkingOff {
t.Errorf("level = %q, want off (env over project/global)", got)
}
}
// TestResolveThinkingLevelProjectOverGlobal verifies the project layer overrides
// the global layer when env and flag are unset.
func TestResolveThinkingLevelProjectOverGlobal(t *testing.T) {
home := isolateConfig(t)
writeConfig(t, filepath.Join(home, "config.json"), "low")
writeConfig(t, filepath.Join(".pigo", "config.json"), "high")
got, err := ResolveThinkingLevel("")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != agentcore.ThinkingHigh {
t.Errorf("level = %q, want high (project over global)", got)
}
}
// TestResolveThinkingLevelInvalid verifies an unknown value is a hard error
// (surfaced for exit-code mapping), not silently coerced.
func TestResolveThinkingLevelInvalid(t *testing.T) {
isolateConfig(t)
if _, err := ResolveThinkingLevel("turbo"); err == nil {
t.Error("expected error for invalid thinking level, got nil")
}
}
+214
View File
@@ -0,0 +1,214 @@
package run
import (
"fmt"
"sort"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// ToolPolicy is the user-declared tool boundary for a run: the --allowed-tools
// whitelist and the --disallowed-tools blacklist, already normalized by
// SplitToolNames. It is passed as one value rather than two adjacent []string
// parameters so the two lists cannot be swapped at a call site — silently
// inverting a security boundary is exactly the bug that must be impossible.
//
// The zero value means "no restriction" and every operation on it is a no-op.
type ToolPolicy struct {
Allow []string
Deny []string
}
// NewToolPolicy normalizes raw flag values into a policy.
func NewToolPolicy(allowed, disallowed []string) ToolPolicy {
return ToolPolicy{Allow: SplitToolNames(allowed), Deny: SplitToolNames(disallowed)}
}
// IsZero reports whether the policy constrains nothing.
func (p ToolPolicy) IsZero() bool { return len(p.Allow) == 0 && len(p.Deny) == 0 }
// SplitToolNames normalizes raw --allowed-tools / --disallowed-tools values into
// a flat list of tool names. Each value may itself be a comma-separated list, so
// `--allowed-tools "read,grep"` and `--allowed-tools read --allowed-tools grep`
// are equivalent. Entries are lowercased and trimmed, and empty entries dropped,
// so `"read, ,grep"` yields [read grep]. Matching is case-insensitive on purpose:
// users coming from Claude Code write `Read`/`Bash`, which must hit pigo's
// `read`/`bash`.
func SplitToolNames(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, v := range values {
for _, part := range strings.Split(v, ",") {
if n := normalizeToolName(part); n != "" {
out = append(out, n)
}
}
}
if len(out) == 0 {
return nil
}
return out
}
// normalizeToolName is the single definition of how a tool name is compared:
// surrounding whitespace is insignificant and case is ignored.
//
// Note this is deliberately more lenient than runtime.filterToolsByName, which
// backs a skill frontmatter's allowed-tools and matches case-sensitively. The
// two are not unified: this policy is user-facing CLI input where Claude-Code
// habits (Read/Bash) must hit read/bash, whereas skill frontmatter is authored
// against pigo's canonical lowercase names. Keep them separate on purpose.
func normalizeToolName(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// ToolPolicyError reports --allowed-tools / --disallowed-tools entries that name
// no existing tool. It is a usage error — the caller maps it to exit code 2 —
// because silently ignoring a typo is the worst outcome available: the user
// believes a boundary is in force when it is not.
type ToolPolicyError struct {
// UnknownAllowed and UnknownDisallowed are the unrecognized names from each
// flag. Both are reported in one error so a user with two typos fixes both in
// one round rather than one per run.
UnknownAllowed []string
UnknownDisallowed []string
// Available is the sorted set of names that would have been accepted.
Available []string
}
func (e *ToolPolicyError) Error() string {
var parts []string
if len(e.UnknownAllowed) > 0 {
parts = append(parts, fmt.Sprintf("--allowed-tools: unknown tool %s", quoteNames(e.UnknownAllowed)))
}
if len(e.UnknownDisallowed) > 0 {
parts = append(parts, fmt.Sprintf("--disallowed-tools: unknown tool %s", quoteNames(e.UnknownDisallowed)))
}
return fmt.Sprintf("%s (available: %s)", strings.Join(parts, "; "), strings.Join(e.Available, ", "))
}
// quoteNames renders names as a comma-separated quoted list.
func quoteNames(names []string) string {
out := make([]string, len(names))
for i, n := range names {
out[i] = fmt.Sprintf("%q", n)
}
return strings.Join(out, ", ")
}
// ValidateToolPolicy checks every allow/deny entry against the assembled tool
// set. It must run AFTER the full set exists (builtins + memory + task +
// plugins), because plugin and memory tool names are only known at runtime;
// validating right after flag parsing would reject legitimate plugin names.
//
// An empty tool set (--no-tools) skips validation entirely: there is nothing to
// constrain, and reporting every name as unknown would be noise.
func ValidateToolPolicy(tools []agentcore.AgentTool, policy ToolPolicy) error {
if len(tools) == 0 || policy.IsZero() {
return nil
}
known := toolNameSet(tools)
unknownAllow := unknownNames(known, policy.Allow)
unknownDeny := unknownNames(known, policy.Deny)
if len(unknownAllow) == 0 && len(unknownDeny) == 0 {
return nil
}
available := make([]string, 0, len(known))
for n := range known {
available = append(available, n)
}
sort.Strings(available)
return &ToolPolicyError{
UnknownAllowed: unknownAllow,
UnknownDisallowed: unknownDeny,
Available: available,
}
}
// unknownNames returns the entries of names absent from known, preserving input
// order and dropping duplicates so a name repeated twice is reported once.
func unknownNames(known map[string]struct{}, names []string) []string {
var out []string
seen := make(map[string]struct{}, len(names))
for _, n := range names {
if _, ok := known[n]; ok {
continue
}
if _, dup := seen[n]; dup {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out
}
// toolNameSet indexes a tool set by normalized name.
func toolNameSet(tools []agentcore.AgentTool) map[string]struct{} {
set := make(map[string]struct{}, len(tools))
for _, t := range tools {
set[normalizeToolName(t.Name())] = struct{}{}
}
return set
}
// ApplyToolPolicy narrows a tool set to the allow list and then removes the deny
// list. Both empty means no restriction and the input is returned unchanged, so
// the default path is a true no-op.
//
// Deny runs after allow, which makes deny win when a name appears on both sides.
// That ordering is deliberate: the fail-closed reading of a contradictory policy
// is "do not run it".
//
// This filters the set handed to the model, so it sits at the tool-registration
// layer — strictly before the BeforeToolCall confirmation gate. A removed tool is
// never advertised and never dispatchable, which is why --approve (which only
// waives per-call confirmation) cannot widen the boundary.
func ApplyToolPolicy(tools []agentcore.AgentTool, policy ToolPolicy) []agentcore.AgentTool {
if len(tools) == 0 || policy.IsZero() {
return tools
}
allowSet := nameSet(policy.Allow)
denySet := nameSet(policy.Deny)
out := make([]agentcore.AgentTool, 0, len(tools))
for _, t := range tools {
name := normalizeToolName(t.Name())
if len(allowSet) > 0 {
if _, ok := allowSet[name]; !ok {
continue
}
}
if _, denied := denySet[name]; denied {
continue
}
out = append(out, t)
}
return out
}
// nameSet indexes already-normalized names for lookup.
func nameSet(names []string) map[string]struct{} {
if len(names) == 0 {
return nil
}
set := make(map[string]struct{}, len(names))
for _, n := range names {
set[n] = struct{}{}
}
return set
}
// ChildToolSet builds the tool set for a task sub-agent: the builtins with
// "task" removed (the nesting guard capping delegation depth at one), narrowed by
// the parent's policy.
//
// Inheriting the policy is load-bearing, not a nicety. A child that ignored it
// would be a one-line escape from the boundary: under --disallowed-tools bash the
// model could dispatch a sub-agent and run bash there instead. Any future spawn
// path must route through here for the same reason.
func ChildToolSet(cwd string, policy ToolPolicy) []agentcore.AgentTool {
return ApplyToolPolicy(BuiltinToolsExcept(cwd, false, "task"), policy)
}
@@ -0,0 +1,212 @@
package run
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// writePolicySkill drops a minimal valid skill into dir so LoadSkills has
// something to advertise.
func writePolicySkill(t *testing.T, dir, name, description string) {
t.Helper()
body := "---\nname: " + name + "\ndescription: " + description + "\n---\nDo the thing."
if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(body), 0o644); err != nil {
t.Fatalf("write skill %s: %v", name, err)
}
}
// setupToolNames runs SetupEnv with a policy and returns the resulting tool
// names. The provider is never contacted, so a stub model id is fine; --no-skills
// keeps the run independent of the machine's skills directory.
func setupToolNames(t *testing.T, policy ToolPolicy) []string {
t.Helper()
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir()) // isolate plugin/skill discovery
env, err := SetupEnv("openrouter/free", "", "", "", "", false /*noTools*/, true /*noSkills*/, "", nil, false /*memEnabled*/, policy)
if err != nil {
t.Fatalf("SetupEnv: %v", err)
}
return names(env.Tools)
}
// contains reports whether name is in the set.
func contains(set []string, name string) bool {
for _, n := range set {
if n == name {
return true
}
}
return false
}
// TestSetupEnvAppliesAllowList confirms a whitelist narrows the advertised set.
// The `task` tool is expected to survive only when explicitly allowed.
func TestSetupEnvAppliesAllowList(t *testing.T) {
got := setupToolNames(t, NewToolPolicy([]string{"read,grep"}, nil))
want := []string{"read", "grep"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("tool set = %q, want exactly %q", got, want)
}
}
// TestSetupEnvAppliesDenyList confirms a blacklist removes the named tools while
// leaving everything else — including the side-effect tools not named — in place.
func TestSetupEnvAppliesDenyList(t *testing.T) {
got := setupToolNames(t, NewToolPolicy(nil, []string{"bash", "bash_output", "kill_bash"}))
for _, denied := range []string{"bash", "bash_output", "kill_bash"} {
if contains(got, denied) {
t.Errorf("%q survived the deny list: %q", denied, got)
}
}
for _, kept := range []string{"read", "write", "edit", "grep"} {
if !contains(got, kept) {
t.Errorf("%q was removed but was not denied: %q", kept, got)
}
}
}
// TestSetupEnvDenyWinsOverAllow is the fail-closed guarantee: a tool named on
// both sides is removed.
func TestSetupEnvDenyWinsOverAllow(t *testing.T) {
got := setupToolNames(t, NewToolPolicy([]string{"read", "bash"}, []string{"bash"}))
if contains(got, "bash") {
t.Errorf("bash was on both lists and must be removed, got %q", got)
}
if !contains(got, "read") {
t.Errorf("read was allowed and not denied, so it must survive, got %q", got)
}
}
// TestSetupEnvUnconstrainedIsUnchanged is the zero-regression check: no policy
// means the full built-in set, including the side-effect tools.
func TestSetupEnvUnconstrainedIsUnchanged(t *testing.T) {
got := setupToolNames(t, ToolPolicy{})
for _, want := range []string{"read", "write", "edit", "grep", "find", "bash", "todo", "webfetch", "websearch", "task"} {
if !contains(got, want) {
t.Errorf("unconstrained run is missing %q: %q", want, got)
}
}
}
// TestSetupEnvRejectsUnknownToolName confirms a typo aborts setup with a
// ToolPolicyError, which is what maps to exit code 2 rather than a run that
// silently ignores the boundary.
func TestSetupEnvRejectsUnknownToolName(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir())
_, err := SetupEnv("openrouter/free", "", "", "", "", false, true, "", nil, false, NewToolPolicy([]string{"raed"}, nil))
if err == nil {
t.Fatal("SetupEnv = nil error, want a failure for the misspelled tool name")
}
var policyErr *ToolPolicyError
if !errors.As(err, &policyErr) {
t.Fatalf("error type = %T, want *ToolPolicyError", err)
}
}
// TestChildToolSetInheritsPolicy closes the sub-agent escape hatch: a child
// dispatched by the task tool must not regain a tool the parent's policy removed,
// or `--disallowed-tools bash` would be bypassable by delegating.
func TestChildToolSetInheritsPolicy(t *testing.T) {
child := names(ChildToolSet("/tmp", NewToolPolicy(nil, []string{"bash"})))
if contains(child, "bash") {
t.Errorf("child regained the denied bash tool: %q", child)
}
if contains(child, "task") {
t.Errorf("child must not contain task (nesting guard): %q", child)
}
if !contains(child, "read") {
t.Errorf("child lost an un-denied tool: %q", child)
}
allowOnly := names(ChildToolSet("/tmp", NewToolPolicy([]string{"read"}, nil)))
if strings.Join(allowOnly, ",") != "read" {
t.Errorf("child under an allow list = %q, want exactly [read]", allowOnly)
}
// With no policy the child is the plain nesting-guarded builtin set.
unconstrained := names(ChildToolSet("/tmp", ToolPolicy{}))
if !contains(unconstrained, "bash") || contains(unconstrained, "task") {
t.Errorf("unconstrained child set = %q, want builtins minus task", unconstrained)
}
}
// TestSetupEnvSkillsGatedOnFilteredReadTool covers the ordering dependency: the
// <available_skills> block is advertised only when `read` survives the policy,
// because the model needs read to load a skill body. Filtering must therefore
// happen before the system prompt is built.
func TestSetupEnvSkillsGatedOnFilteredReadTool(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir())
skillsDir := t.TempDir()
t.Setenv("PIGO_SKILLS_DIR", skillsDir)
writePolicySkill(t, skillsDir, "weather", "get the weather")
withRead, err := SetupEnv("openrouter/free", "", "", "", "", false, false, "", nil, false, ToolPolicy{})
if err != nil {
t.Fatalf("SetupEnv (unconstrained): %v", err)
}
if !strings.Contains(withRead.SysPrompt, "<available_skills>") {
t.Fatal("unconstrained run must advertise skills; the fixture or gate is wrong")
}
withoutRead, err := SetupEnv("openrouter/free", "", "", "", "", false, false, "", nil, false, NewToolPolicy(nil, []string{"read"}))
if err != nil {
t.Fatalf("SetupEnv (read denied): %v", err)
}
if strings.Contains(withoutRead.SysPrompt, "available_skills") {
t.Error("denying read must suppress <available_skills>: the model could not load a skill body")
}
}
// captureStderr runs fn with os.Stderr redirected to a pipe and returns whatever
// was written. It is not safe under t.Parallel — these tests must stay serial.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
orig := os.Stderr
os.Stderr = w
defer func() { os.Stderr = orig }()
fn()
w.Close()
out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read captured stderr: %v", err)
}
return string(out)
}
// TestSetupEnvNoToolsWithPolicyWarns is the counterpart to the typo guarantee:
// under --no-tools the set is empty, so ValidateToolPolicy cannot flag a
// misspelled name. Rather than let the boundary silently vanish, SetupEnv must
// still succeed but print a warning that the policy is inert — otherwise a user
// combining --no-tools with a (possibly misspelled) --allowed-tools would
// believe a boundary is in force when none is.
func TestSetupEnvNoToolsWithPolicyWarns(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir())
var env Env
var err error
stderr := captureStderr(t, func() {
// A deliberately misspelled name: with tools present this would abort with
// exit code 2, but --no-tools skips validation, so it must not error.
env, err = SetupEnv("openrouter/free", "", "", "", "", true /*noTools*/, true /*noSkills*/, "", nil, false, NewToolPolicy([]string{"raed"}, nil))
})
if err != nil {
t.Fatalf("SetupEnv(--no-tools + policy) = %v, want nil (validation is skipped, not failed)", err)
}
if len(env.Tools) != 0 {
t.Errorf("--no-tools must leave no tools, got %q", names(env.Tools))
}
if !strings.Contains(stderr, "--no-tools disables all tools") {
t.Errorf("expected an inert-policy warning on stderr, got %q", stderr)
}
}
+176
View File
@@ -0,0 +1,176 @@
package run
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// policyTool is a minimal AgentTool whose only meaningful property is its name —
// the tool policy matches on nothing else.
type policyTool struct{ name string }
func (t policyTool) Name() string { return t.name }
func (t policyTool) Description() string { return "stub" }
func (t policyTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object"}`)
}
func (t policyTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionParallel
}
func (t policyTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return agentcore.AgentToolResult{}, nil
}
// toolSet builds a tool set from names.
func toolSet(names ...string) []agentcore.AgentTool {
out := make([]agentcore.AgentTool, 0, len(names))
for _, n := range names {
out = append(out, policyTool{name: n})
}
return out
}
// names extracts the tool names from a set, for comparison.
func names(tools []agentcore.AgentTool) []string {
out := make([]string, 0, len(tools))
for _, t := range tools {
out = append(out, t.Name())
}
return out
}
// TestSplitToolNames covers the accepted input forms: a single value, repeated
// flags, comma-separated values, mixed forms, and whitespace/empty entries.
func TestSplitToolNames(t *testing.T) {
tests := []struct {
name string
in []string
want []string
}{
{"nil", nil, nil},
{"single", []string{"read"}, []string{"read"}},
{"repeated flag", []string{"read", "grep"}, []string{"read", "grep"}},
{"comma", []string{"read,grep"}, []string{"read", "grep"}},
{"mixed", []string{"read,grep", "bash"}, []string{"read", "grep", "bash"}},
{"whitespace and empties", []string{"read, ,grep", " bash "}, []string{"read", "grep", "bash"}},
{"case folded", []string{"Read", "BASH"}, []string{"read", "bash"}},
{"only empties", []string{"", " ", ",,"}, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SplitToolNames(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("SplitToolNames(%q) = %q, want %q", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("SplitToolNames(%q) = %q, want %q", tc.in, got, tc.want)
}
}
})
}
}
// TestApplyToolPolicy covers allow-only, deny-only, the overlap (deny wins), the
// unconstrained no-op, and filtering down to an empty set.
func TestApplyToolPolicy(t *testing.T) {
all := toolSet("read", "write", "bash", "grep")
tests := []struct {
name string
policy ToolPolicy
want []string
}{
{"unconstrained is a no-op", ToolPolicy{}, []string{"read", "write", "bash", "grep"}},
{"allow only", NewToolPolicy([]string{"read,grep"}, nil), []string{"read", "grep"}},
{"deny only", NewToolPolicy(nil, []string{"bash"}), []string{"read", "write", "grep"}},
{"deny wins over allow", NewToolPolicy([]string{"read,bash"}, []string{"bash"}), []string{"read"}},
{"case-insensitive allow", NewToolPolicy([]string{"Read", "GREP"}, nil), []string{"read", "grep"}},
{"case-insensitive deny", NewToolPolicy(nil, []string{"Bash"}), []string{"read", "write", "grep"}},
{"filters to empty", NewToolPolicy([]string{"read"}, []string{"read"}), nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := names(ApplyToolPolicy(all, tc.policy))
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("ApplyToolPolicy = %q, want %q", got, tc.want)
}
})
}
}
// TestApplyToolPolicyEmptyToolSet confirms an empty input (--no-tools) is left
// alone rather than being treated as "everything denied".
func TestApplyToolPolicyEmptyToolSet(t *testing.T) {
if got := ApplyToolPolicy(nil, NewToolPolicy([]string{"read"}, nil)); got != nil {
t.Errorf("ApplyToolPolicy(nil, ...) = %v, want nil", got)
}
}
// TestValidateToolPolicyAccepts confirms known names — including case variants —
// pass validation.
func TestValidateToolPolicyAccepts(t *testing.T) {
all := toolSet("read", "bash")
for _, policy := range []ToolPolicy{
{},
NewToolPolicy([]string{"read"}, nil),
NewToolPolicy([]string{"Read"}, []string{"BASH"}),
NewToolPolicy(nil, []string{"bash"}),
} {
if err := ValidateToolPolicy(all, policy); err != nil {
t.Errorf("ValidateToolPolicy(%+v) = %v, want nil", policy, err)
}
}
}
// TestValidateToolPolicyReportsAllUnknown is the anti-typo guarantee: every bad
// name from both flags is reported in one error, alongside the available names,
// so a user with two typos fixes both in one round.
func TestValidateToolPolicyReportsAllUnknown(t *testing.T) {
all := toolSet("read", "bash", "grep")
err := ValidateToolPolicy(all, NewToolPolicy([]string{"raed,gerp"}, []string{"bahs"}))
if err == nil {
t.Fatal("ValidateToolPolicy = nil, want an error for the misspelled names")
}
var policyErr *ToolPolicyError
if !errors.As(err, &policyErr) {
t.Fatalf("error type = %T, want *ToolPolicyError (exit-code mapping depends on it)", err)
}
if len(policyErr.UnknownAllowed) != 2 {
t.Errorf("UnknownAllowed = %q, want both misspellings", policyErr.UnknownAllowed)
}
if len(policyErr.UnknownDisallowed) != 1 {
t.Errorf("UnknownDisallowed = %q, want the one misspelling", policyErr.UnknownDisallowed)
}
msg := err.Error()
for _, want := range []string{`"raed"`, `"gerp"`, `"bahs"`, "available:", "read", "bash", "grep"} {
if !strings.Contains(msg, want) {
t.Errorf("error message %q is missing %q", msg, want)
}
}
}
// TestValidateToolPolicyDeduplicatesUnknown confirms a name repeated across
// values is reported once.
func TestValidateToolPolicyDeduplicatesUnknown(t *testing.T) {
err := ValidateToolPolicy(toolSet("read"), NewToolPolicy([]string{"raed", "raed"}, nil))
var policyErr *ToolPolicyError
if !errors.As(err, &policyErr) {
t.Fatalf("error = %v, want *ToolPolicyError", err)
}
if len(policyErr.UnknownAllowed) != 1 {
t.Errorf("UnknownAllowed = %q, want one entry", policyErr.UnknownAllowed)
}
}
// TestValidateToolPolicySkipsEmptyToolSet confirms --no-tools does not turn every
// policy name into an error.
func TestValidateToolPolicySkipsEmptyToolSet(t *testing.T) {
if err := ValidateToolPolicy(nil, NewToolPolicy([]string{"anything"}, nil)); err != nil {
t.Errorf("ValidateToolPolicy(nil tools) = %v, want nil", err)
}
}