Skip to content
Open
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
6 changes: 6 additions & 0 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
m.hintsLoader = engine.NewHintsLoader()
m.sourceRoots = engine.NewSourceRoots()
m.selfImprover = engine.NewSelfImprover()
// Close the self-improvement loop: failure reflections produced by the
// engine's Reflector are persisted to the cross-session lesson store and
// injected back via ForPrompt on later submits.
if sess != nil {
sess.SetLearnFn(m.selfImprover.Learn)
}
m.codingSoul = engine.LoadCodingSoul()
startup.EndPhase("newChatModel:bmad-features")

Expand Down
29 changes: 28 additions & 1 deletion internal/engine/permission_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package engine
import (
"context"
"fmt"
"os"
"strings"
"sync"

"github.com/GrayCodeAI/hawk/internal/engine/safety"
"github.com/GrayCodeAI/hawk/internal/governance"
"github.com/GrayCodeAI/hawk/internal/observability/logger"
"github.com/GrayCodeAI/hawk/internal/permissions"
"github.com/GrayCodeAI/hawk/internal/sandbox"
Expand Down Expand Up @@ -59,14 +61,39 @@ func NewPermissionService(log *logger.Logger) *PermissionService {
log = logger.Default()
}
pe := NewPermissionEngine()
return &PermissionService{
s := &PermissionService{
perm: pe,
memory: pe.Memory,
autoMode: pe.AutoMode,
classifier: pe.Classifier,
bypassKill: pe.BypassKill,
log: log,
}
s.loadManagedGovernance()
return s
}

// loadManagedGovernance attempts to install the administrator-set POLICY
// ceiling from the platform trust-root (see governance.ManagedPolicyPath).
// A missing file is the normal unmanaged case and leaves the engine
// fail-open; a malformed file is logged loudly so misconfiguration is
// visible without hard-failing every session.
func (s *PermissionService) loadManagedGovernance() {
if s == nil || s.perm == nil || s.perm.Governance == nil {
return
}
path := governance.ManagedPolicyPath()
if path == "" {
return
}
if err := s.perm.Governance.LoadPolicy(path); err != nil {
if !os.IsNotExist(err) {
s.log.Error("governance: failed to load managed policy", map[string]interface{}{
"path": path,
"error": err.Error(),
})
}
}
}

// WithEngine replaces the underlying PermissionEngine. Used by tests
Expand Down
19 changes: 19 additions & 0 deletions internal/engine/safety/permission_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

contracts "github.com/GrayCodeAI/hawk-core-contracts/policy"
"github.com/GrayCodeAI/hawk/internal/governance"
"github.com/GrayCodeAI/hawk/internal/hooks"
"github.com/GrayCodeAI/hawk/internal/permissions"
"github.com/GrayCodeAI/hawk/internal/sandbox"
Expand Down Expand Up @@ -74,6 +75,11 @@ type PermissionEngine struct {
Phases int // total number of phases detected from tasks.md
convergeChecked bool // whether convergence has been checked this session
PromptFn func(PermissionRequest) // callback to ask user
// Governance is the POLICY ∩ PROFILE ceiling. It is evaluated before
// every other gate (hooks, spec stage, rules, autonomy, bypass) so no
// agent state or user-granted bypass can loosen an administrator-set
// ceiling. Nil means fail-open (no governance policy installed).
Governance *governance.Engine
}

// DecisionOutcome is the result of evaluating a tool request.
Expand Down Expand Up @@ -104,6 +110,7 @@ const (
ReasonClassifiedSafe DecisionReason = "classified_safe"
ReasonUserPrompt DecisionReason = "user_prompt"
ReasonPromptUnavailable DecisionReason = "prompt_unavailable"
ReasonGovernance DecisionReason = "governance"
)

// Decision is the structured result of a permission evaluation.
Expand Down Expand Up @@ -155,6 +162,7 @@ func NewPermissionEngine() *PermissionEngine {
AutoMode: permissions.NewAutoModeState(),
Classifier: permissions.NewClassifier(),
BypassKill: permissions.NewBypassKillswitch(),
Governance: governance.New(),
}
}

Expand Down Expand Up @@ -221,6 +229,17 @@ func (pe *PermissionEngine) evaluateToolDecision(ctx context.Context, tc ToolCal
return Decision{Outcome: DecisionDeny, Reason: ReasonDryRun, Message: "dry-run: tool execution disabled"}
}

// Governance ceiling — evaluated first so no later gate (hooks, spec
// stage, remembered rules, autonomy, bypass kill-switch) can override an
// administrator-set POLICY ∩ PROFILE decision. This is the un-disableable
// org ceiling: deny in either layer denies regardless of what the agent
// or user grants at lower layers.
if pe.Governance != nil {
if d := pe.Governance.Evaluate(tc.Name, ToolSummary(tc.Name, tc.Args)); !d.Allowed {
return Decision{Outcome: DecisionDeny, Reason: ReasonGovernance, Message: d.Reason}
}
}

toolName := canonicalToolName(tc.Name)

// PreToolUse decision hooks — deny gate before autonomy. Hooks that
Expand Down
67 changes: 67 additions & 0 deletions internal/engine/safety/permission_engine_governance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package safety

import (
"context"
"testing"

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

// govPolicy builds a policy layer with the given capabilities.
func govPolicy(caps ...governance.Capability) *governance.Layer {
l, err := governance.BuildProfile("policy", governance.Document{
Version: 1,
FailClosed: true,
Capabilities: caps,
})
if err != nil {
panic(err)
}
return l
}

func TestGovernanceCeilingOverridesBypass(t *testing.T) {
pe := NewPermissionEngine()
pe.Governance = governance.New()
pe.Governance.SetPolicy(govPolicy(
governance.Capability{Scope: "bash", Action: governance.ActionDeny},
))
pe.BypassKill.Enable()

d := pe.CheckToolDecision(context.Background(), ToolCallInfo{
Name: "Bash",
Args: map[string]interface{}{"command": "rm -rf /"},
})
if d.Outcome != DecisionDeny || d.Reason != ReasonGovernance {
t.Fatalf("governance deny must override bypass: %+v", d)
}
}

func TestGovernanceCeilingOverridesAutonomyAndRules(t *testing.T) {
pe := NewPermissionEngine()
pe.Governance = governance.New()
pe.Governance.SetPolicy(govPolicy(
governance.Capability{Scope: "bash", Action: governance.ActionDeny},
))
pe.Autonomy = AutonomyYOLO
pe.Memory.AlwaysAllow("Bash")

d := pe.CheckToolDecision(context.Background(), ToolCallInfo{
Name: "Bash",
Args: map[string]interface{}{"command": "echo hi"},
})
if d.Outcome != DecisionDeny || d.Reason != ReasonGovernance {
t.Fatalf("governance deny must override autonomy+rule allow: %+v", d)
}
}

func TestGovernanceUnconfiguredIsFailOpen(t *testing.T) {
pe := NewPermissionEngine()
d := pe.CheckToolDecision(context.Background(), ToolCallInfo{
Name: "Bash",
Args: map[string]interface{}{"command": "echo hi"},
})
if d.Reason == ReasonGovernance {
t.Fatalf("unconfigured governance must not interfere: %+v", d)
}
}
29 changes: 27 additions & 2 deletions internal/engine/self_improve.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
"time"

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

// maxSelfImproveEntries caps the persisted lesson store so a long-lived
// machine does not accumulate an unbounded file. Oldest entries are dropped.
const maxSelfImproveEntries = 200

// SelfImproveEntry records a lesson learned from a mistake.
type SelfImproveEntry struct {
Timestamp time.Time `json:"timestamp"`
Expand All @@ -23,6 +28,7 @@ type SelfImproveEntry struct {
type SelfImprover struct {
Path string
Entries []SelfImproveEntry
mu sync.Mutex
}

// NewSelfImprover loads or creates the improvement log.
Expand All @@ -33,22 +39,36 @@ func NewSelfImprover() *SelfImprover {
return si
}

// Learn records a new lesson.
// Learn records a new lesson. It is nil-safe and bounded: oldest entries are
// dropped past maxSelfImproveEntries so the store cannot grow without limit.
func (si *SelfImprover) Learn(what, why, lesson, category string) {
if si == nil {
return
}
si.mu.Lock()
defer si.mu.Unlock()
si.Entries = append(si.Entries, SelfImproveEntry{
Timestamp: time.Now(),
What: what,
Why: why,
Lesson: lesson,
Category: category,
})
if len(si.Entries) > maxSelfImproveEntries {
si.Entries = si.Entries[len(si.Entries)-maxSelfImproveEntries:]
}
si.save()
}

// Lessons returns all lessons, optionally filtered by category.
func (si *SelfImprover) Lessons(category string) []SelfImproveEntry {
if si == nil {
return nil
}
si.mu.Lock()
defer si.mu.Unlock()
if category == "" {
return si.Entries
return append([]SelfImproveEntry{}, si.Entries...)
}
var filtered []SelfImproveEntry
for _, e := range si.Entries {
Expand All @@ -61,6 +81,11 @@ func (si *SelfImprover) Lessons(category string) []SelfImproveEntry {

// ForPrompt formats recent lessons as context for the system prompt.
func (si *SelfImprover) ForPrompt(maxEntries int) string {
if si == nil {
return ""
}
si.mu.Lock()
defer si.mu.Unlock()
if len(si.Entries) == 0 {
return ""
}
Expand Down
57 changes: 57 additions & 0 deletions internal/engine/self_improve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package engine

import (
"path/filepath"
"testing"
)

func TestSelfImproverLearnAndForPrompt(t *testing.T) {
si := &SelfImprover{Path: filepath.Join(t.TempDir(), "self-improve.json")}
si.Learn("write failed", "wrong encoding", "always verify encoding", "code")
si.Learn("test flaked", "race", "use -race", "test")

out := si.ForPrompt(5)
if out == "" {
t.Fatal("expected lessons in prompt")
}
if len(si.Lessons("code")) != 1 {
t.Fatalf("expected 1 code lesson, got %d", len(si.Lessons("code")))
}
}

func TestSelfImproverBounded(t *testing.T) {
si := &SelfImprover{Path: filepath.Join(t.TempDir(), "self-improve.json")}
for i := 0; i < maxSelfImproveEntries+50; i++ {
si.Learn("x", "y", "z", "code")
}
if len(si.Entries) != maxSelfImproveEntries {
t.Fatalf("expected %d entries, got %d", maxSelfImproveEntries, len(si.Entries))
}
}

func TestSelfImproverNilSafe(t *testing.T) {
var si *SelfImprover
si.Learn("x", "y", "z", "code") // must not panic
if out := si.ForPrompt(5); out != "" {
t.Fatalf("expected empty prompt for nil improver, got %q", out)
}
if lessons := si.Lessons("code"); lessons != nil {
t.Fatalf("expected nil lessons for nil improver")
}
}

func TestSessionLearnCallback(t *testing.T) {
si := &SelfImprover{Path: filepath.Join(t.TempDir(), "self-improve.json")}
s := &Session{}
s.SetLearnFn(si.Learn)
s.Learn("bash failed", "bad path", "quote paths", "tool_failure")

if len(si.Entries) != 1 || si.Entries[0].Category != "tool_failure" {
t.Fatalf("expected 1 persisted lesson, got %+v", si.Entries)
}
}

func TestSessionLearnNilSafe(t *testing.T) {
s := &Session{}
s.Learn("x", "y", "z", "code") // no callback, must not panic
}
35 changes: 35 additions & 0 deletions internal/engine/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ type Session struct {
memory *MemoryService
persist *PersistenceService
tools *ToolService
// learnFn persists structured lessons produced by failure reflection to a
// cross-session store (e.g. the chat client's SelfImprover). It is a
// callback so the engine stays decoupled from storage; nil disables it.
learnFn func(what, why, lesson, category string)
// GLMThinkingEnabled toggles GLM/Z.ai extended reasoning on outgoing requests
// (applied only when provider is zai_payg or zai_coding). nil leaves the model default.

Expand Down Expand Up @@ -218,6 +222,11 @@ func (s *Session) SubSession(model, systemPrompt string, registry *tool.Registry
deploymentRouting = llm.DeploymentRouting()
}
sub := NewSessionWithClient(chat, provider, model, systemPrompt, registry, deploymentRouting)
// Propagate the lesson callback so sub-agent failures also persist
// cross-session lessons.
s.mu.RLock()
sub.learnFn = s.learnFn
s.mu.RUnlock()
return sub
}

Expand Down Expand Up @@ -279,6 +288,32 @@ func (s *Session) PermSvc() *PermissionService { return s.perms }
// LifecycleSvc returns the extracted LifecycleService (Phase 3).
func (s *Session) LifecycleSvc() *LifecycleService { return s.life }

// SetLearnFn installs the callback that persists structured lessons produced
// by failure reflection. Set it to a SelfImprover.Learn-compatible function
// to close the loop: reflections then survive the session.
func (s *Session) SetLearnFn(fn func(what, why, lesson, category string)) {
if s == nil {
return
}
s.mu.Lock()
s.learnFn = fn
s.mu.Unlock()
}

// Learn persists a lesson through the configured callback. Safe to call with
// nil session or no callback installed.
func (s *Session) Learn(what, why, lesson, category string) {
if s == nil {
return
}
s.mu.RLock()
fn := s.learnFn
s.mu.RUnlock()
if fn != nil {
fn(what, why, lesson, category)
}
}

// MemorySvc returns the extracted MemoryService (Phase 4).
func (s *Session) MemorySvc() *MemoryService { return s.memory }

Expand Down
4 changes: 4 additions & 0 deletions internal/engine/stream_tool_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa
reflection, refErr := s.LifecycleSvc().Reflector().Reflect(ctx, intentText, s.Persistence().RawMessages(), output)
if refErr == nil && reflection != nil {
output += fmt.Sprintf("\n\n## Self-Reflection\n**What failed:** %s\n**Why:** %s\n**What to do differently:** %s\nTry a different approach based on this analysis.", reflection.WhatFailed, reflection.WhyFailed, reflection.WhatToDo)
// Persist the lesson across sessions so future runs avoid
// the same mistake. Learn is nil-safe when no callback is
// installed (e.g. headless or background sessions).
s.Learn(reflection.WhatFailed, reflection.WhyFailed, reflection.WhatToDo, "tool_failure")
}
}
} else {
Expand Down
Loading
Loading