Skip to content

Commit 64b2bd1

Browse files
authored
Merge pull request #2538 from dgageot/board/extracting-runtime-components-as-builtin-b822c9ff
feat(hooks): add 6 builtin hooks + widen post_tool_use / before_llm_call contract
2 parents b27e9d2 + 8afc6a4 commit 64b2bd1

24 files changed

Lines changed: 1500 additions & 122 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package builtins
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
"sort"
9+
"strings"
10+
11+
"github.com/docker/docker-agent/pkg/hooks"
12+
)
13+
14+
// AddDirectoryListing is the registered name of the add_directory_listing builtin.
15+
const AddDirectoryListing = "add_directory_listing"
16+
17+
// maxDirectoryEntries caps how many entries from the cwd top level are
18+
// included in the listing. A very large project root would otherwise
19+
// dominate every prompt. Hidden entries (dot-files) are skipped before
20+
// the cap is applied so the visible budget isn't burned on
21+
// configuration noise.
22+
const maxDirectoryEntries = 100
23+
24+
// addDirectoryListing emits a one-shot listing of the working
25+
// directory's top-level entries as session_start additional context.
26+
//
27+
// Hidden entries (names starting with ".") are skipped; the rest are
28+
// sorted alphabetically with a trailing "/" on directories. Output is
29+
// capped at [maxDirectoryEntries] with a "... and N more" line when
30+
// truncated. Read errors are logged at debug and produce a nil Output
31+
// rather than aborting session start.
32+
//
33+
// No-op when Input.Cwd is empty or the listing has no visible entries.
34+
func addDirectoryListing(_ context.Context, in *hooks.Input, _ []string) (*hooks.Output, error) {
35+
if in == nil || in.Cwd == "" {
36+
return nil, nil
37+
}
38+
39+
entries, err := os.ReadDir(in.Cwd)
40+
if err != nil {
41+
slog.Debug("add_directory_listing: read dir failed; skipping", "cwd", in.Cwd, "error", err)
42+
return nil, nil
43+
}
44+
45+
var names []string
46+
for _, e := range entries {
47+
name := e.Name()
48+
if strings.HasPrefix(name, ".") {
49+
continue
50+
}
51+
if e.IsDir() {
52+
name += "/"
53+
}
54+
names = append(names, name)
55+
}
56+
if len(names) == 0 {
57+
return nil, nil
58+
}
59+
sort.Strings(names)
60+
61+
truncated := 0
62+
if len(names) > maxDirectoryEntries {
63+
truncated = len(names) - maxDirectoryEntries
64+
names = names[:maxDirectoryEntries]
65+
}
66+
67+
var b strings.Builder
68+
fmt.Fprintf(&b, "Top-level entries in %s:\n\n", in.Cwd)
69+
for _, n := range names {
70+
b.WriteString("- ")
71+
b.WriteString(n)
72+
b.WriteByte('\n')
73+
}
74+
if truncated > 0 {
75+
fmt.Fprintf(&b, "... and %d more\n", truncated)
76+
}
77+
78+
return hooks.NewAdditionalContextOutput(hooks.EventSessionStart, strings.TrimRight(b.String(), "\n")), nil
79+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package builtins_test
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/docker/docker-agent/pkg/hooks"
13+
"github.com/docker/docker-agent/pkg/hooks/builtins"
14+
)
15+
16+
// TestAddDirectoryListingIncludesFilesAndDirs verifies the happy path:
17+
// regular files appear by name, directories get a trailing "/", and
18+
// dot-files are excluded. We deliberately don't pin the header line
19+
// format — the test is robust to header tweaks.
20+
func TestAddDirectoryListingIncludesFilesAndDirs(t *testing.T) {
21+
t.Parallel()
22+
23+
dir := t.TempDir()
24+
writeFile(t, dir, "README.md", "")
25+
writeFile(t, dir, "main.go", "")
26+
writeFile(t, dir, ".env", "secret") // hidden — must be skipped
27+
require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0o755))
28+
require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0o755)) // hidden dir — skipped
29+
30+
fn := lookup(t, builtins.AddDirectoryListing)
31+
32+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s", Cwd: dir}, nil)
33+
require.NoError(t, err)
34+
require.NotNil(t, out)
35+
require.NotNil(t, out.HookSpecificOutput)
36+
assert.Equal(t, hooks.EventSessionStart, out.HookSpecificOutput.HookEventName,
37+
"add_directory_listing must target session_start, not turn_start")
38+
39+
ctx := out.HookSpecificOutput.AdditionalContext
40+
assert.Contains(t, ctx, "README.md")
41+
assert.Contains(t, ctx, "main.go")
42+
assert.Contains(t, ctx, "subdir/", "directories must be marked with a trailing slash")
43+
assert.NotContains(t, ctx, ".env", "hidden files must be skipped")
44+
assert.NotContains(t, ctx, ".git", "hidden directories must be skipped")
45+
}
46+
47+
// TestAddDirectoryListingTruncatesLongList pins the cap behavior: with
48+
// more than maxDirectoryEntries (100) visible entries, the output ends
49+
// with a truncation summary. We assert on the "... and N more" line
50+
// without locking in the exact format of every entry.
51+
func TestAddDirectoryListingTruncatesLongList(t *testing.T) {
52+
t.Parallel()
53+
54+
dir := t.TempDir()
55+
for i := range 150 {
56+
writeFile(t, dir, fmt.Sprintf("file_%03d.txt", i), "")
57+
}
58+
59+
fn := lookup(t, builtins.AddDirectoryListing)
60+
61+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s", Cwd: dir}, nil)
62+
require.NoError(t, err)
63+
require.NotNil(t, out)
64+
assert.Contains(t, out.HookSpecificOutput.AdditionalContext, "and 50 more",
65+
"truncation summary must report the dropped count")
66+
}
67+
68+
// TestAddDirectoryListingEmptyOrNoCwdIsNoop documents the two no-op
69+
// paths: an empty Cwd (the safety check) and a Cwd whose only entries
70+
// are hidden (no visible content to report) both produce nil rather
71+
// than an empty stanza.
72+
func TestAddDirectoryListingEmptyOrNoCwdIsNoop(t *testing.T) {
73+
t.Parallel()
74+
75+
fn := lookup(t, builtins.AddDirectoryListing)
76+
77+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, nil)
78+
require.NoError(t, err)
79+
assert.Nil(t, out)
80+
81+
out, err = fn(t.Context(), nil, nil)
82+
require.NoError(t, err)
83+
assert.Nil(t, out)
84+
85+
hiddenOnly := t.TempDir()
86+
writeFile(t, hiddenOnly, ".hidden", "")
87+
out, err = fn(t.Context(), &hooks.Input{SessionID: "s", Cwd: hiddenOnly}, nil)
88+
require.NoError(t, err)
89+
assert.Nil(t, out, "directory with only hidden entries must yield no output")
90+
}
91+
92+
// TestAddDirectoryListingUnreadableDirIsNoop documents the graceful
93+
// failure path: a Cwd that doesn't exist (or can't be read) makes the
94+
// builtin skip rather than abort the session. ReadDir's error is
95+
// logged at debug level.
96+
func TestAddDirectoryListingUnreadableDirIsNoop(t *testing.T) {
97+
t.Parallel()
98+
99+
fn := lookup(t, builtins.AddDirectoryListing)
100+
101+
out, err := fn(t.Context(), &hooks.Input{
102+
SessionID: "s",
103+
Cwd: filepath.Join(t.TempDir(), "does-not-exist"),
104+
}, nil)
105+
require.NoError(t, err)
106+
assert.Nil(t, out)
107+
}

pkg/hooks/builtins/add_git_diff.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package builtins
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
7+
"github.com/docker/docker-agent/pkg/hooks"
8+
)
9+
10+
// AddGitDiff is the registered name of the add_git_diff builtin.
11+
const AddGitDiff = "add_git_diff"
12+
13+
// addGitDiff emits the working-tree diff as turn_start additional
14+
// context, refreshing every turn.
15+
//
16+
// By default it runs `git diff --stat` for a compact summary. Pass the
17+
// single arg "full" to emit the full unified diff (`git diff`) instead.
18+
// In either mode the captured output is capped by [maxGitOutputBytes],
19+
// so a runaway change set can't silently blow up the prompt.
20+
//
21+
// No-op when:
22+
// - Input.Cwd is empty;
23+
// - the directory isn't a git repo (git exits non-zero);
24+
// - git isn't installed;
25+
// - the diff is empty (clean tree).
26+
func addGitDiff(ctx context.Context, in *hooks.Input, args []string) (*hooks.Output, error) {
27+
if in == nil || in.Cwd == "" {
28+
return nil, nil
29+
}
30+
31+
gitArgs := []string{"diff", "--stat"}
32+
header := "Current working-tree diff (stat):\n\n"
33+
if len(args) > 0 && args[0] == "full" {
34+
gitArgs = []string{"diff"}
35+
header = "Current working-tree diff:\n\n"
36+
}
37+
38+
out, err := gitOutput(ctx, in.Cwd, gitArgs...)
39+
if err != nil {
40+
slog.Debug("add_git_diff: git diff failed; skipping", "cwd", in.Cwd, "error", err)
41+
return nil, nil
42+
}
43+
if out == "" {
44+
return nil, nil
45+
}
46+
return hooks.NewAdditionalContextOutput(hooks.EventTurnStart, header+out), nil
47+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package builtins_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/hooks"
10+
"github.com/docker/docker-agent/pkg/hooks/builtins"
11+
)
12+
13+
// TestAddGitDiffStatModeShowsTouchedFile verifies the default mode
14+
// (`git diff --stat`): after staging an initial commit and modifying a
15+
// tracked file, the diff output must mention the file. We anchor the
16+
// assertion on the filename rather than the stat formatting so the
17+
// test is stable across git versions.
18+
func TestAddGitDiffStatModeShowsTouchedFile(t *testing.T) {
19+
t.Parallel()
20+
21+
dir := initGitRepo(t)
22+
writeFile(t, dir, "README.md", "first")
23+
runGit(t, dir, "add", "README.md")
24+
runGit(t, dir, "commit", "--quiet", "-m", "init")
25+
// Modify the tracked file so `git diff` (working-tree vs HEAD)
26+
// has something to report.
27+
writeFile(t, dir, "README.md", "second")
28+
29+
fn := lookup(t, builtins.AddGitDiff)
30+
31+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s", Cwd: dir}, nil)
32+
require.NoError(t, err)
33+
require.NotNil(t, out)
34+
require.NotNil(t, out.HookSpecificOutput)
35+
assert.Equal(t, hooks.EventTurnStart, out.HookSpecificOutput.HookEventName,
36+
"add_git_diff must target turn_start, not session_start")
37+
assert.Contains(t, out.HookSpecificOutput.AdditionalContext, "README.md")
38+
assert.Contains(t, out.HookSpecificOutput.AdditionalContext, "stat",
39+
"default mode must announce itself as the stat view")
40+
}
41+
42+
// TestAddGitDiffFullModeShowsHunks pins the args contract: passing the
43+
// single arg "full" switches from `--stat` to a full unified diff. We
44+
// assert on the presence of a "+second" line, which only appears in
45+
// the unified output, not in --stat.
46+
func TestAddGitDiffFullModeShowsHunks(t *testing.T) {
47+
t.Parallel()
48+
49+
dir := initGitRepo(t)
50+
writeFile(t, dir, "README.md", "first\n")
51+
runGit(t, dir, "add", "README.md")
52+
runGit(t, dir, "commit", "--quiet", "-m", "init")
53+
writeFile(t, dir, "README.md", "second\n")
54+
55+
fn := lookup(t, builtins.AddGitDiff)
56+
57+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s", Cwd: dir}, []string{"full"})
58+
require.NoError(t, err)
59+
require.NotNil(t, out)
60+
assert.Contains(t, out.HookSpecificOutput.AdditionalContext, "+second",
61+
"full mode must include the unified-diff hunk lines")
62+
}
63+
64+
// TestAddGitDiffCleanTreeIsNoop documents that a clean working tree
65+
// produces a nil Output rather than an empty stanza. The model should
66+
// not see "Current working-tree diff (stat):\n" with nothing under it.
67+
func TestAddGitDiffCleanTreeIsNoop(t *testing.T) {
68+
t.Parallel()
69+
70+
dir := initGitRepo(t)
71+
writeFile(t, dir, "README.md", "first")
72+
runGit(t, dir, "add", "README.md")
73+
runGit(t, dir, "commit", "--quiet", "-m", "init")
74+
75+
fn := lookup(t, builtins.AddGitDiff)
76+
77+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s", Cwd: dir}, nil)
78+
require.NoError(t, err)
79+
assert.Nil(t, out, "clean tree must yield no additional context")
80+
}
81+
82+
// TestAddGitDiffNoCwdOrNotARepoIsNoop documents the safety / graceful
83+
// failure paths: empty Cwd, nil input, and a non-repo directory all
84+
// produce nil rather than aborting the session.
85+
func TestAddGitDiffNoCwdOrNotARepoIsNoop(t *testing.T) {
86+
t.Parallel()
87+
88+
fn := lookup(t, builtins.AddGitDiff)
89+
90+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, nil)
91+
require.NoError(t, err)
92+
assert.Nil(t, out)
93+
94+
out, err = fn(t.Context(), nil, nil)
95+
require.NoError(t, err)
96+
assert.Nil(t, out)
97+
98+
out, err = fn(t.Context(), &hooks.Input{SessionID: "s", Cwd: t.TempDir()}, nil)
99+
require.NoError(t, err)
100+
assert.Nil(t, out)
101+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package builtins
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
7+
"github.com/docker/docker-agent/pkg/hooks"
8+
)
9+
10+
// AddGitStatus is the registered name of the add_git_status builtin.
11+
const AddGitStatus = "add_git_status"
12+
13+
// addGitStatus emits `git status --short --branch` as turn_start
14+
// additional context, refreshing the model's view of uncommitted
15+
// changes every turn.
16+
//
17+
// No-op when:
18+
// - Input.Cwd is empty;
19+
// - the directory isn't a git repo (git exits non-zero);
20+
// - git isn't installed.
21+
//
22+
// Failures are logged at debug and surfaced as a nil Output so an
23+
// agent configured to receive git status doesn't get the run aborted
24+
// because the user happened to `cd` outside a repo.
25+
func addGitStatus(ctx context.Context, in *hooks.Input, _ []string) (*hooks.Output, error) {
26+
if in == nil || in.Cwd == "" {
27+
return nil, nil
28+
}
29+
out, err := gitOutput(ctx, in.Cwd, "status", "--short", "--branch")
30+
if err != nil {
31+
slog.Debug("add_git_status: git status failed; skipping", "cwd", in.Cwd, "error", err)
32+
return nil, nil
33+
}
34+
if out == "" {
35+
return nil, nil
36+
}
37+
return hooks.NewAdditionalContextOutput(hooks.EventTurnStart, "Current git status:\n\n"+out), nil
38+
}

0 commit comments

Comments
 (0)