first commit
This commit is contained in:
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user