Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ func optionalTools() []tool.Tool {
tool.DiagnosticsTool{},
tool.CodeSearchTool{},
tool.CodeMatchTool{},
tool.ToolsetTool{},
tool.CoreMemoryAppendTool{},
tool.CoreMemoryReplaceTool{},
tool.CoreMemoryRethinkTool{},
Expand Down
1 change: 1 addition & 0 deletions internal/engine/safety/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var toolPolicies = map[string]ToolPolicy{
"SmartRead": {Name: "SmartRead", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeSearch": {Name: "CodeSearch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeMatch": {Name: "CodeMatch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"Toolset": {Name: "Toolset", Capabilities: nil, DefaultRisk: RiskLow},
"CodeGraph": {Name: "CodeGraph", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"Impact": {Name: "Impact", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"GitHistory": {Name: "GitHistory", Capabilities: []Capability{CapabilityFilesystemRead, CapabilityProcessExecute}, DefaultRisk: RiskLow},
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/safety/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ func canonicalToolName(name string) string {
return "WebSearch"
case "code_match", "codematch", "match_code":
return "CodeMatch"
case "toolset":
return "Toolset"
case "tool_health", "toolhealth", "tools_health":
return "ToolHealth"
case "project_verify", "projectverify", "verify_project":
Expand Down
135 changes: 135 additions & 0 deletions internal/engine/trajectory/trajectory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Package trajectory implements trajectory compression for agent runs,
// adopting Hermes Agent's trajectory_compressor: protect the first and last
// N turns verbatim (including their tool calls), compress only the middle into
// a single human summary message, and keep the surviving tool calls intact so
// training/eval signal is preserved.
package trajectory

import (
"strings"

"github.com/GrayCodeAI/hawk/internal/types"
)

// bytesPerToken is the rough character-based token estimate.
const bytesPerToken = 4

// estTokens estimates the token cost of a message from its text plus tool
// payload sizes.
func estTokens(m types.EyrieMessage) int {
n := len(m.Content)
for _, tu := range m.ToolUse {
if s, ok := tu.Arguments["_raw"]; ok {
if str, ok := s.(string); ok {
n += len(str)
}
}
for _, v := range tu.Arguments {
if str, ok := v.(string); ok {
n += len(str)
}
}
}
for _, tr := range m.ToolResults {
n += len(tr.Content)
}
return n / bytesPerToken
}

// CompressTrajectory returns a trajectory where the first protectFirst turns
// and the last protectLast turns are kept verbatim and the middle is replaced
// by a single human summary message, only when the total exceeds
// targetTokens. The summary is a plain text "user" message so downstream
// consumers treat it as a faithful human-supplied checkpoint. Tool calls in
// the kept head/tail remain intact.
//
// Returns (msgs, false) unchanged when already under budget or there is no
// compressible middle.
func CompressTrajectory(msgs []types.EyrieMessage, targetTokens, protectFirst, protectLast int) ([]types.EyrieMessage, bool) {
if len(msgs) == 0 || targetTokens <= 0 {
return msgs, false
}
total := 0
for _, m := range msgs {
total += estTokens(m)
}
if total <= targetTokens {
return msgs, false
}
if protectFirst < 0 {
protectFirst = 0
}
if protectLast < 0 {
protectLast = 0
}
// Ensure head and tail do not overlap.
if protectFirst+protectLast >= len(msgs) {
return msgs, false // no middle to compress
}

head := msgs[:protectFirst]
tail := msgs[len(msgs)-protectLast:]
middle := msgs[protectFirst : len(msgs)-protectLast]

summary := summarizeMiddle(middle)
out := make([]types.EyrieMessage, 0, len(head)+len(tail)+1)
out = append(out, head...)
out = append(out, types.EyrieMessage{
Role: "user",
Content: "[compressed middle: " + summary + "]",
})
out = append(out, tail...)
return out, true
}

// summarizeMiddle reduces the middle region to a compact one-line digest. It
// counts turns and tool calls and captures the last user request, which is the
// most task-relevant signal for a human checkpoint.
func summarizeMiddle(middle []types.EyrieMessage) string {
turns := len(middle)
toolCalls := 0
lastUser := ""
for _, m := range middle {
toolCalls += len(m.ToolUse)
if m.Role == "user" && strings.TrimSpace(m.Content) != "" {
lastUser = strings.TrimSpace(m.Content)
}
}
var b strings.Builder
b.WriteString("summarized ")
b.WriteString(itoa(turns))
b.WriteString(" turns, ")
b.WriteString(itoa(toolCalls))
b.WriteString(" tool calls")
if lastUser != "" {
lastUser = strings.ReplaceAll(lastUser, "\n", " ")
if len(lastUser) > 160 {
lastUser = lastUser[:160] + "…"
}
b.WriteString("; latest request: ")
b.WriteString(lastUser)
}
return b.String()
}

func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
86 changes: 86 additions & 0 deletions internal/engine/trajectory/trajectory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package trajectory

import (
"strings"
"testing"

"github.com/GrayCodeAI/hawk/internal/types"
)

func bigToolMsg(payload string) types.EyrieMessage {
return types.EyrieMessage{
Role: "assistant",
ToolUse: []types.ToolCall{{Name: "Bash", Arguments: map[string]interface{}{"command": payload}}},
ToolResults: []types.ToolResult{{Content: payload}},
}
}

func TestUnderBudgetUnchanged(t *testing.T) {
msgs := []types.EyrieMessage{{Role: "user", Content: "hi"}}
out, changed := CompressTrajectory(msgs, 10000, 2, 2)
if changed || len(out) != 1 {
t.Fatal("small trajectory must be unchanged")
}
}

func TestProtectHeadTailCompressMiddle(t *testing.T) {
msgs := []types.EyrieMessage{
{Role: "user", Content: "start"},
bigToolMsg(strings.Repeat("a", 4000)), // head turn with tool call
{Role: "user", Content: strings.Repeat("b", 3000)},
{Role: "assistant", Content: strings.Repeat("c", 3000)},
{Role: "user", Content: strings.Repeat("d", 3000)},
{Role: "user", Content: "end"}, // tail
}
// protectFirst=2, protectLast=1; target small enough to force compression
out, changed := CompressTrajectory(msgs, 400, 2, 1)
if !changed {
t.Fatal("expected compression")
}
// head (2) + summary + tail (1) = 4 messages
if len(out) != 4 {
t.Fatalf("len = %d, want 4", len(out))
}
// head verbatim (incl. tool call)
if out[0].Content != "start" {
t.Fatalf("head[0] altered: %q", out[0].Content)
}
if len(out[1].ToolUse) != 1 || len(out[1].ToolResults) != 1 {
t.Fatal("head tool call not preserved")
}
// summary
if out[2].Role != "user" || !strings.Contains(out[2].Content, "[compressed middle") {
t.Fatalf("summary missing: %+v", out[2])
}
// tail verbatim
if out[3].Content != "end" {
t.Fatalf("tail altered: %q", out[3].Content)
}
}

func TestNoMiddleWhenProtectCoversAll(t *testing.T) {
msgs := []types.EyrieMessage{
{Role: "user", Content: "a"},
{Role: "user", Content: strings.Repeat("b", 5000)},
{Role: "user", Content: "c"},
}
out, changed := CompressTrajectory(msgs, 100, 2, 2)
if changed || len(out) != 3 {
t.Fatal("overlapping protect must leave unchanged")
}
}

func TestSummaryCapturesLatestRequest(t *testing.T) {
msgs := []types.EyrieMessage{
{Role: "user", Content: "old"},
{Role: "user", Content: "the real request here"},
{Role: "assistant", Content: strings.Repeat("x", 3000)},
}
out, changed := CompressTrajectory(msgs, 200, 1, 0)
if !changed {
t.Fatal("expected change")
}
if !strings.Contains(out[1].Content, "the real request here") {
t.Fatalf("summary lost latest request: %q", out[1].Content)
}
}
23 changes: 23 additions & 0 deletions internal/intelligence/skillcurator/record.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package skillcurator

import (
"path/filepath"

"github.com/GrayCodeAI/hawk/internal/storage"
)

// RecordSkillUsage is a best-effort, non-blocking integration point for the
// Skill tool: it records a skill invocation in the user-scoped curator so the
// auto-archive review has accurate usage data. It never errors — skill
// execution must not be interrupted by curator bookkeeping.
func RecordSkillUsage(name string) {
if name == "" {
return
}
dir := filepath.Join(storage.StateDir(), "skills")
c, err := New(Config{SkillsDir: dir, StateFile: filepath.Join(dir, ".curator_state.json")})
if err != nil {
return
}
c.RecordUse(name)
}
Loading
Loading