Skip to content

Commit 3a1a69e

Browse files
authored
feat(consolidation): cache-segment planner; curator+toolset CLI; docs (#242)
Completes the A (cache planning) and C (consolidation) items from the cocoindex/hermes adoption queue. Cache-segment planner (internal/engine/cache_planner.go): - planCache segments the stable prefix (system prompt, tool catalog), emits one breakpoint per segment, and applies measured break-even economics (Anthropic 5m: write=1.25x, read=0.1x; caching pays off for reuse >= 2). Returns a structured CachePlan with costs and saving so hosts can log/observe the decision. - Fixes an economics bug vs the old gate's arithmetic: read cost is 0.1x per reused turn (the old comment conflated the discount with the cost). cacheDecision now delegates to planCache at default reuse; behavior is preserved for existing tests (non-anthropic off, small prefix off, large prefix on). - Empty tool catalogs no longer create a phantom 'tools' segment from json.Marshal(nil). Consolidation CLI surfaces: - hawk skills curator [status|run|pin|unpin|archive]: exposes the Hermes- style background skill curator as an explicit CLI (ForceReview bypasses the inactivity interval for on-demand runs). - hawk toolset [name]: lists or resolves named composable tool groups. Docs: - New 'Adopted Capabilities' README table mapping every env-gated feature/flag/command added across the adoption series so they are discoverable in one place. Verification: new planner tests (6) green; engine/cmd/skillcurator/ toolset suites pass incl. updated golden help; golangci-lint 0 issues; gofmt clean; go build ./... clean.
1 parent 2f1a1e8 commit 3a1a69e

8 files changed

Lines changed: 385 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,27 @@ Define personas and eval tasks in YAML (in addition to markdown personas), inclu
260260

261261
An optional, non-excludable org-policy rule tier (highest precedence) with HTML-comment stripping of rule files for IT-managed deployments.
262262

263+
### Adopted Capabilities (env-gated)
264+
265+
Features adopted from open-source agent projects. All are off by default unless explicitly enabled; see each section for details.
266+
267+
| Feature | Flag / Command | What it does |
268+
|---|---|---|
269+
| Best-of-N fan-out | `hawk exec --fanout N` | Run the same prompt in N isolated worktrees, compare, merge winner |
270+
| Completion notifications | `HAWK_NOTIFY_WEBHOOK_URL` / `HAWK_NOTIFY_TELEGRAM_TOKEN` + `_CHAT_ID` | Webhook or Telegram ping when a run finishes |
271+
| Incremental system-context | `HAWK_INCREMENTAL_CONTEXT=1` | Reconcile dynamic sections instead of rebuilding the prompt |
272+
| Tool-catalog shrink | `HAWK_TOOL_SHRINK=1` | Compress the tool catalog sent on every request |
273+
| Compaction segments | `HAWK_COMPACTION_SEGMENT_DETAIL=verbose\|balanced\|minimal\|none` | Persist verbatim compacted turns to disk |
274+
| Skill curator | `hawk skills curator status/run/pin/unpin/archive` + `HAWK_SKILL_CURATOR=1` | Auto-archive cold agent-created skills (recoverable) |
275+
| Structural code match | `CodeMatch` tool | Tree-sitter query search over Go/Python/TS/TSX |
276+
| Composable toolsets | `hawk toolset [name]` + `Toolset` tool | Named tool groups (research, dev, ops, full_stack) |
277+
| App verification | `AppVerify` tool | Boot-smoke check with readiness polling and evidence artifacts |
278+
| Media generation | `GenerateMedia` tool (needs a wired engine) | Image/video generation with local persistence |
279+
| Git-tree file snapshots | `internal/gitsnapshot` | Content-addressed tree capture/diff/preview/restore |
280+
| Turn-boundary rewind | `internal/filestate` | Per-prompt before/after snapshots with durable store |
281+
| Path reservations | `internal/multiagent` ledger | Detect overlapping-file changes between parallel branches |
282+
| Live agent status | `GET /v1/agent/status` (daemon) | Machine-readable working/idle/stale per session |
283+
263284
## Usage
264285

265286
### Interactive Mode

‎cmd/skills_curator_cmd.go‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/GrayCodeAI/hawk/internal/intelligence/skillcurator"
8+
"github.com/GrayCodeAI/hawk/internal/storage"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// skillsCuratorCmd exposes the background skill curator (adopted from Hermes
13+
// Agent) as a CLI surface: review/archive/pin/unpin over agent-created skills
14+
// in ~/.hawk/skills.
15+
var skillsCuratorCmd = &cobra.Command{
16+
Use: "curator [command]",
17+
Short: "Skill lifecycle curation (status, run, pin, unpin, archive)",
18+
Long: `Maintain the agent-created skill collection.
19+
20+
status List skills with lifecycle status and usage
21+
run Run the inactivity review now (archives cold skills)
22+
pin <name> Pin a skill (auto-transitions skip pinned skills)
23+
unpin <name> Remove a pin
24+
archive <name> Move a skill to .archive/ (recoverable, never deleted)
25+
26+
The review is inactivity-triggered: only agent-created skills that have been
27+
used before and have gone cold are archived; installed third-party skills,
28+
never-used skills, and pinned skills are left alone.`,
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
if len(args) == 0 {
31+
return cmd.Help()
32+
}
33+
c, err := newCurator()
34+
if err != nil {
35+
return err
36+
}
37+
switch args[0] {
38+
case "status", "list":
39+
skills, err := c.List()
40+
if err != nil {
41+
return err
42+
}
43+
if len(skills) == 0 {
44+
fmt.Println("No curated skills found.")
45+
return nil
46+
}
47+
for _, s := range skills {
48+
last := "-"
49+
if !s.LastUsed.IsZero() {
50+
last = s.LastUsed.Format("2006-01-02")
51+
}
52+
fmt.Printf("%-24s %-9s uses=%-4d last=%s\n", s.Name, s.Status, s.UseCount, last)
53+
}
54+
return nil
55+
case "run":
56+
archived, err := c.ForceReview()
57+
if err != nil {
58+
return err
59+
}
60+
if len(archived) == 0 {
61+
fmt.Println("Review complete: nothing to archive.")
62+
return nil
63+
}
64+
fmt.Printf("Archived %d cold skill(s):\n", len(archived))
65+
for _, n := range archived {
66+
fmt.Printf(" - %s (recoverable from .archive/)\n", n)
67+
}
68+
return nil
69+
case "pin":
70+
return requireArg(args, func(name string) error { return c.Pin(name) })
71+
case "unpin":
72+
return requireArg(args, func(name string) error { return c.Unpin(name) })
73+
case "archive":
74+
return requireArg(args, func(name string) error { return c.Archive(name, "archived via CLI") })
75+
default:
76+
return fmt.Errorf("unknown curator command %q (use status, run, pin, unpin, archive)", args[0])
77+
}
78+
},
79+
}
80+
81+
func requireArg(args []string, fn func(string) error) error {
82+
if len(args) < 2 {
83+
return fmt.Errorf("%s requires a skill name", args[0])
84+
}
85+
return fn(args[1])
86+
}
87+
88+
func newCurator() (*skillcurator.Curator, error) {
89+
dir := filepath.Join(storage.StateDir(), "skills")
90+
return skillcurator.New(skillcurator.Config{
91+
SkillsDir: dir,
92+
StateFile: filepath.Join(dir, ".curator_state.json"),
93+
})
94+
}
95+
96+
func init() {
97+
skillsCmd.AddCommand(skillsCuratorCmd)
98+
}

‎cmd/toolset_cmd.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/GrayCodeAI/hawk/internal/toolset"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// toolsetCmd exposes named, composable tool groups (adopted from Hermes
13+
// Agent's toolset system) as a CLI surface: list the available groups or
14+
// resolve one to its concrete, de-duplicated, sorted tool list.
15+
var toolsetCmd = &cobra.Command{
16+
Use: "toolset [name]",
17+
Short: "List or resolve composable tool groups",
18+
Long: `Named, composable tool groups for scoping an agent's tool surface.
19+
20+
hawk toolset List available toolsets
21+
hawk toolset research Resolve 'research' to its concrete tool list
22+
23+
Toolsets compose from other toolsets; resolving expands Requires
24+
transitively (cycle-safe) and de-duplicates.`,
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
reg, err := toolset.NewRegistry(toolset.Defaults())
27+
if err != nil {
28+
return err
29+
}
30+
if len(args) == 0 {
31+
fmt.Println("Available toolsets: " + strings.Join(reg.Names(), ", "))
32+
return nil
33+
}
34+
name := args[0]
35+
tools, err := reg.Resolve(name)
36+
if err != nil {
37+
return err
38+
}
39+
payload := map[string]interface{}{"toolset": name, "tools": tools, "count": len(tools)}
40+
out, _ := json.MarshalIndent(payload, "", " ")
41+
fmt.Println(string(out))
42+
return nil
43+
},
44+
}
45+
46+
func init() {
47+
rootCmd.AddCommand(toolsetCmd)
48+
}

‎internal/engine/cache_gate.go‎

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,9 @@
11
package engine
22

3-
import (
4-
"encoding/json"
5-
"strings"
6-
7-
"github.com/GrayCodeAI/hawk/internal/types"
8-
)
9-
10-
// Prompt-cache break-even gate, adopting caveman's cacheengine arithmetic in
11-
// miniature: provider-native caching charges a write premium on cached input
12-
// (Anthropic 5m: write=1.25x, read=0.1x) and pays off only when the stable
13-
// prefix is reused. Below the break-even prefix size the premium costs more
14-
// than one reuse saves, so caching stays OFF rather than burning the write.
15-
//
16-
// Full segment planning and key-sharding belong in eyrie; this is the
17-
// client-side gate only.
18-
19-
// cacheMinPrefixBytes is the smallest stable prefix worth a cache write.
20-
// ~8 KiB approximates 2k tokens: at Anthropic economics, two reuses of a
21-
// 2k-token prefix already beat paying full price twice (2x1.0 > 1.25+0.1).
22-
const cacheMinPrefixBytes = 8 * 1024
23-
24-
// cacheDecision reports whether to request provider-native prompt caching
25-
// for this call. Deterministic and pure so it can be tested without a
26-
// provider connection.
27-
func cacheDecision(provider, systemPrompt string, tools []types.EyrieTool) bool {
28-
if !strings.EqualFold(provider, "anthropic") {
29-
return false // other providers: implicit caching; no explicit controls
30-
}
31-
stable := len(systemPrompt)
32-
if raw, err := json.Marshal(tools); err == nil {
33-
stable += len(raw)
34-
}
35-
return stable >= cacheMinPrefixBytes
36-
}
3+
// Prompt-cache break-even gate: provider-native caching charges a write
4+
// premium on cached input (Anthropic 5m: write=1.25x, read=0.1x) and pays off
5+
// only when the stable prefix is reused. The deterministic client planner in
6+
// cache_planner.go (planCache / cacheDecision) implements this arithmetic:
7+
// segments the stable prefix, computes breakpoints, and enables caching only
8+
// when the expected reuse count beats the write premium. Full wire-format
9+
// lowering and fleet-wide key-sharding remain eyrie-side.

‎internal/engine/cache_planner.go‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package engine
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
7+
"github.com/GrayCodeAI/hawk/internal/types"
8+
)
9+
10+
// Prompt-cache segment planning, extending the caveman-style break-even gate
11+
// into a full client-side planner: the stable prefix (system prompt + tool
12+
// catalog) is split into cacheable segments, breakpoints are computed at the
13+
// last tool and last system boundaries, and the whole thing is enabled only
14+
// when the provider economics pay off for the expected reuse count. Full
15+
// wire-format lowering and key sharding across a fleet remain eyrie-side;
16+
// this is the deterministic client planner.
17+
18+
// Anthropic 5m cache economics: uncached = 1.0x, cache write = 1.25x,
19+
// cache read = 0.1x per reuse. The write premium (0.25x) is paid once.
20+
const (
21+
cacheWritePremium = 0.25
22+
cacheReadCost = 0.1 // per-reuse cost of reading a cached segment
23+
// cacheMinSegmentBytes: segments below this are not worth a cache write.
24+
cacheMinSegmentBytes = 8 * 1024
25+
// cacheDefaultReuse is the expected number of turns reusing the prefix.
26+
cacheDefaultReuse = 2
27+
// cacheMinPrefixBytes is the historical name for the minimum stable-prefix
28+
// size worth a cache write (alias for cacheMinSegmentBytes).
29+
cacheMinPrefixBytes = cacheMinSegmentBytes
30+
)
31+
32+
// CacheSegment is one cacheable unit of the stable prefix.
33+
type CacheSegment struct {
34+
Index int `json:"index"`
35+
Label string `json:"label"` // "system" | "tools"
36+
Bytes int `json:"bytes"`
37+
}
38+
39+
// CachePlan is the outcome of cache planning for one call.
40+
type CachePlan struct {
41+
Enabled bool `json:"enabled"`
42+
Provider string `json:"provider"`
43+
Segments []CacheSegment `json:"segments"`
44+
Breakpoints int `json:"breakpoints"` // number of cache breakpoints to emit
45+
ReuseCount int `json:"reuse_count"`
46+
WriteBytes int `json:"write_bytes"`
47+
ReadSaving int `json:"read_saving_bytes"`
48+
UncachedCost int `json:"uncached_cost_bytes"`
49+
CachedCost int `json:"cached_cost_bytes"`
50+
Reason string `json:"reason,omitempty"`
51+
}
52+
53+
// planCache computes whether and how to cache the stable prefix for a call.
54+
// Deterministic and pure so it can be tested without a provider connection.
55+
func planCache(provider, systemPrompt string, tools []types.EyrieTool, expectedReuse int) CachePlan {
56+
if expectedReuse <= 0 {
57+
expectedReuse = cacheDefaultReuse
58+
}
59+
if !strings.EqualFold(provider, "anthropic") {
60+
return CachePlan{Provider: provider, ReuseCount: expectedReuse, Reason: "no explicit cache controls for provider"}
61+
}
62+
63+
plan := CachePlan{Provider: "anthropic", ReuseCount: expectedReuse}
64+
total := 0
65+
if systemPrompt != "" {
66+
plan.Segments = append(plan.Segments, CacheSegment{Index: 0, Label: "system", Bytes: len(systemPrompt)})
67+
total += len(systemPrompt)
68+
}
69+
if len(tools) > 0 {
70+
if raw, err := json.Marshal(tools); err == nil && len(raw) > 0 {
71+
plan.Segments = append(plan.Segments, CacheSegment{Index: len(plan.Segments), Label: "tools", Bytes: len(raw)})
72+
total += len(raw)
73+
}
74+
}
75+
plan.WriteBytes = total
76+
77+
if len(plan.Segments) == 0 {
78+
plan.Reason = "no stable prefix to cache"
79+
return plan
80+
}
81+
82+
// A tiny prefix is not worth the write premium (matches the old gate).
83+
if total < cacheMinSegmentBytes {
84+
plan.Reason = "prefix below break-even size"
85+
return plan
86+
}
87+
88+
// Breakpoints: one at the last system boundary and one at the last tool
89+
// boundary. We emit breakpoints on every segment so a later prefix reuse
90+
// hits an early breakpoint (Anthropic caches at the nearest breakpoint
91+
// before reused content). 2 segments -> 2 breakpoints.
92+
plan.Breakpoints = len(plan.Segments)
93+
94+
// Economics: break-even reuse R satisfies
95+
// R*S > 1.25*S + (R-1)*0.1*S => R > 1.25 + 0.1*(R-1)
96+
// => 0.9*R > 1.15 => R > 1.278
97+
// So caching pays off for reuse >= 2. Compute costs for the given reuse.
98+
plan.UncachedCost = total * expectedReuse
99+
write := int(float64(total) * (1 + cacheWritePremium))
100+
reads := int(float64(total) * cacheReadCost * float64(expectedReuse-1))
101+
plan.CachedCost = write + reads
102+
plan.ReadSaving = plan.UncachedCost - plan.CachedCost
103+
104+
if plan.ReadSaving <= 0 {
105+
plan.Reason = "caching does not beat uncached at expected reuse"
106+
return plan
107+
}
108+
plan.Enabled = true
109+
plan.Reason = "break-even satisfied"
110+
return plan
111+
}
112+
113+
// cacheDecision reports whether to request provider-native caching, delegating
114+
// to the planner at the default reuse. Kept for backward compatibility.
115+
func cacheDecision(provider, systemPrompt string, tools []types.EyrieTool) bool {
116+
return planCache(provider, systemPrompt, tools, cacheDefaultReuse).Enabled
117+
}

0 commit comments

Comments
 (0)