Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2ee8c3d
docs: remove generic LLM boilerplate ai_passage.md
Patel230 Jun 12, 2026
44e79e3
Merge branch 'main' of github.com:GrayCodeAI/hawk
Patel230 Jun 12, 2026
1e5f03c
feat(tool): bash safety hardening + schema-aware extract + retry policy
Patel230 Jun 12, 2026
8f156cf
refactor(engine): extract ChatService (Phase 1 of Session god-object …
Patel230 Jun 12, 2026
06af93f
style(chat_service_test): apply gofumpt formatting
Patel230 Jun 12, 2026
f867c30
refactor(engine): extract PermissionService + LifecycleService (Phase…
Patel230 Jun 12, 2026
bd11615
refactor(engine): extract MemoryService + PersistenceService + ToolSe…
Patel230 Jun 12, 2026
a402cf9
refactor(engine): wire 6 sub-services into Session (Phase 7 partial)
Patel230 Jun 12, 2026
cd75eeb
docs(stream): clarify the two max_tokens recovery strategies
Patel230 Jun 12, 2026
f8bd8c8
style(engine): apply gofumpt to sub-service files
Patel230 Jun 12, 2026
c12e015
fix(engine): remove unused fields caught by golangci-lint
Patel230 Jun 12, 2026
a7c027a
refactor(engine): wire 6 sub-services in NewSession + migrate stream.…
Patel230 Jun 12, 2026
094dfff
style(engine): apply gofumpt formatting
Patel230 Jun 12, 2026
3978e67
ci: re-run all checks
Patel230 Jun 12, 2026
67287d6
Merge branch 'refactor/session-services-composition' into tmp/merge38
Patel230 Jun 12, 2026
e94461a
Merge origin/main into refactor/stream-uses-sub-services
Patel230 Jun 12, 2026
04a994d
style(engine): goimports merged stream.go
Patel230 Jun 12, 2026
8db8bc2
fix(tool): remove duplicated readonly helpers after merge
Patel230 Jun 12, 2026
ff2377c
Merge origin/main into refactor/stream-uses-sub-services after main a…
Patel230 Jun 12, 2026
8d8db98
style(engine): gofumpt merged files
Patel230 Jun 12, 2026
9e5a786
Merge remote-tracking branch 'origin/main' into tmp/hawk-pr38-format
Patel230 Jun 12, 2026
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
34 changes: 11 additions & 23 deletions internal/engine/chat_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,14 +174,21 @@ func (c *ChatService) BuildOptions(systemPrompt, activeModel string, maxTokens i
return opts
}

// Stream issues a streaming LLM call with retry, rate-limit, and circuit-
// breaker accounting. The returned *types.StreamResult's Events channel
// emits EyrieStreamEvent values; the caller must Close() the result when
// done.
// Stream issues a streaming LLM call with retry, rate-limit, and
// emergency-compact. The returned *types.StreamResult's Events channel
// emits EyrieStreamEvent values; the caller must Close() the result
// when done.
//
// On context cancellation mid-call, returns the cancellation error wrapped
// with whatever partial state the upstream had emitted (caller should
// check ctx.Err()).
//
// Note: the ChatService intentionally does NOT touch the legacy circuit-
// breaker router on success/failure. The Session-level agent loop
// (stream.go) is responsible for that recording, because it has the full
// apiStart timestamp it wants to feed to Router.RecordSuccess. Putting
// that responsibility here would either duplicate the call or force the
// service to invent a "started at" argument that doesn't otherwise exist.
func (c *ChatService) Stream(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.StreamResult, error) {
// Rate limit: wait for a token before making the LLM call
if c.rateLimiter != nil {
Expand All @@ -204,10 +211,8 @@ func (c *ChatService) Stream(ctx context.Context, messages []types.EyrieMessage,
return callErr
})
if err != nil {
c.recordFailure(err)
return nil, err
}
c.recordSuccess()
return result, nil
}

Expand All @@ -218,23 +223,6 @@ func (c *ChatService) Chat(ctx context.Context, messages []types.EyrieMessage, o
return c.client.Chat(ctx, messages, opts)
}

// recordSuccess records a successful LLM call against the legacy circuit-
// breaker router. No-op when DeploymentRouting is on (the DeploymentRouter
// has its own breakers).
func (c *ChatService) recordSuccess() {
if c.router != nil && !c.deploymentRouting {
c.router.RecordSuccess(c.provider, 0)
}
}

// recordFailure records a failed LLM call against the legacy circuit-
// breaker router. No-op when DeploymentRouting is on.
func (c *ChatService) recordFailure(err error) {
if c.router != nil && !c.deploymentRouting {
c.router.RecordFailure(c.provider, err)
}
}

// isContextOverflow reports whether err looks like a "context too long"
// error from the upstream provider. Used by Stream() to trigger an
// emergency context-compact + retry.
Expand Down
5 changes: 5 additions & 0 deletions internal/engine/client_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ type ChatClient interface {
}

// SetTestClient replaces the session's LLM client. For testing only.
// Also reattaches the ChatService so the agent loop's `s.ChatLLM().Stream`
// call site sees the mock (Phase 7 migration).
func (s *Session) SetTestClient(c ChatClient) {
s.client = c
if s.llm != nil {
s.llm.Reattach(c, s.provider)
}
}

// NewMockClientForTest creates a mock ChatClient that returns canned text responses.
Expand Down
195 changes: 195 additions & 0 deletions internal/engine/lifecycle_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package engine

import (
"context"
"time"

"github.com/GrayCodeAI/hawk/internal/engine/branching"
"github.com/GrayCodeAI/hawk/internal/intelligence/memory"
"github.com/GrayCodeAI/hawk/internal/observability/logger"
"github.com/GrayCodeAI/hawk/internal/prompts"
)

// LifecycleService is the Session's view of the self-improvement and
// observability surface: do-omom-loop detection, snowball detection,
// beliefs, backtrack, limits, critic, shadow, cascade model selection,
// reflect, sleeptime, agent-distill, skill-distill, file-mention
// detection, response caching, steering queue, belief recording, agents
// accumulator, and the few-shot + adaptive-prompt memory. These are
// small but numerous — extracted together in Phase 3 of the
// god-object decomposition (see docs/session-decomposition.md).
//
// All sub-fields are optional. A Session with the defaults
// (LifecycleService{} zero value plus the constructors in New()) is
// fully functional — the agent loop's branching on `if s.X != nil`
// is preserved.
type LifecycleService struct {
// model selection.
cascade *branching.CascadeRouter
// limit tracking.
limits *LimitTracker
// doom-loop / snowball / loop detection.
loopDet *LoopDetector
snowball *branching.SnowballDetector
// beliefs.
beliefs *BeliefState
// decision recording.
backtrack *BacktrackEngine
// post-write critics.
critic *Critic
// pre-edit shadow validation.
shadow *branching.ShadowWorkspace
// verbal self-reflection on tool failure.
reflector *Reflector
// few-shot + adaptive prompt.
fewShotStore *FewShotStore
adaptivePrompt *AdaptivePrompt
// activity tracker.
activity *memory.ActivityTracker
// agents-accumulator (.hawk/agents.md).
agentsAccum *prompts.AgentsAccumulator
// response cache (used in agentLoop for cache hits).
responseCache *ResponseCache
// integration pipeline (pre-query / post-response / end-session).
pipeline *IntegrationPipeline
// steering queue.
steering *SteeringQueue
// session-level lifecycle hook.
lifecycle *SessionLifecycle
// log is the session logger.
log *logger.Logger
}

// NewLifecycleService constructs a LifecycleService with all default
// sub-fields populated. log must be non-nil.
func NewLifecycleService(log *logger.Logger) *LifecycleService {
if log == nil {
log = logger.Default()
}
return &LifecycleService{
limits: NewLimitTracker(DefaultLimits()),
loopDet: NewLoopDetector(10, DoomLoopThreshold),
snowball: branching.NewSnowballDetector(500000),
beliefs: NewBeliefState(),
backtrack: NewBacktrackEngine(),
lifecycle: nil, // constructed in New() with cwd
responseCache: NewResponseCache(1000, 24*time.Hour),
pipeline: NewIntegrationPipeline(),
log: log,
fewShotStore: nil, // lazy
adaptivePrompt: nil, // lazy
}
}

// OnSessionStart is called by Stream() at the beginning of each session.
// Injects learned guidelines + few-shot examples + user-preference
// learning + .hawk/agents.md learnings into the system prompt.
func (s *LifecycleService) OnSessionStart(ctx context.Context, s2 *Session, lastUserMsg string) string {
if s.lifecycle != nil {
if ctx := s.lifecycle.OnSessionStart(ctx, lastUserMsg); ctx != "" {
s2.AppendSystemContext(ctx)
return ctx
}
}
return ""
}

// OnSessionEnd is called by Stream() when the agent loop exits. Runs
// the post-session pipeline: lifecycle postprocess, enhanced-memory
// EndSession, yaad session summary, few-shot pattern storage,
// adaptive-prompt learning feedback.
func (s *LifecycleService) OnSessionEnd(ctx context.Context, s2 *Session, success bool, duration time.Duration) {
if s.lifecycle != nil {
outcome := SessionOutcome{Success: success, Duration: duration}
if len(s2.messages) > 0 {
for _, m := range s2.messages {
if m.Role == "user" && len(m.ToolResults) == 0 && outcome.TaskGoal == "" {
outcome.TaskGoal = m.Content
}
}
}
_ = s.lifecycle.OnSessionEnd(ctx, s2, outcome)
}
if s.adaptivePrompt != nil {
for _, m := range s2.messages {
if m.Role == "user" && len(m.ToolResults) == 0 {
s.adaptivePrompt.LearnFromFeedback(m.Content)
}
}
}
}

// SelectModel picks the optimal model for a turn. Returns the current
// model unchanged if cascade is nil.
func (s *LifecycleService) SelectModel(currentModel, lastUserMsg, hint string) string {
if s.cascade == nil || !s.cascade.Enabled {
return currentModel
}
return s.cascade.SelectModel(lastUserMsg, currentModel, hint)
}

// CheckLimits returns false if the agent loop should stop (max turns
// hit, max tokens hit, doom loop detected, snowball exceeded).
func (s *LifecycleService) CheckLimits(turnCount int) bool {
if s.limits != nil {
s.limits.RecordTurn()
}
if s.loopDet != nil && s.loopDet.IsDoomLoop() {
return false
}
if s.snowball != nil && s.snowball.IsSnowballing() {
return false
}
return true
}

// RecordToolCall updates the per-tool call counter used by limits.
func (s *LifecycleService) RecordToolCall(name string) {
if s.limits != nil {
s.limits.RecordToolCall(name)
}
}

// RecordStep updates the doom-loop detector with the latest tool step.
func (s *LifecycleService) RecordStep(toolNames []string, inputs []string, outputs []string) {
if s.loopDet != nil {
s.loopDet.RecordStep(toolNames, inputs, outputs)
}
}

// SnapshotTurnProgress feeds the snowball detector.
func (s *LifecycleService) SnapshotTurnProgress(tokens int, progress float64) {
if s.snowball != nil {
s.snowball.RecordTurn(tokens, progress)
}
}

// Setter accessors used by NewSessionWithClient and the agent loop
// to wire optional collaborators. All nil-safe.

func (s *LifecycleService) SetCascade(c *branching.CascadeRouter) { s.cascade = c }
func (s *LifecycleService) SetLifecycle(l *SessionLifecycle) { s.lifecycle = l }
func (s *LifecycleService) SetReflector(r *Reflector) { s.reflector = r }
func (s *LifecycleService) SetCritic(c *Critic) { s.critic = c }
func (s *LifecycleService) SetShadow(sh *branching.ShadowWorkspace) { s.shadow = sh }
func (s *LifecycleService) SetFewShotStore(f *FewShotStore) { s.fewShotStore = f }
func (s *LifecycleService) SetAdaptivePrompt(a *AdaptivePrompt) { s.adaptivePrompt = a }
func (s *LifecycleService) SetActivity(act *memory.ActivityTracker) { s.activity = act }
func (s *LifecycleService) SetAgentsAccum(a *prompts.AgentsAccumulator) { s.agentsAccum = a }
func (s *LifecycleService) SetSteering(st *SteeringQueue) { s.steering = st }

// Accessors used by stream.go and the agent loop. nil-safe.
func (s *LifecycleService) Beliefs() *BeliefState { return s.beliefs }
func (s *LifecycleService) Backtrack() *BacktrackEngine { return s.backtrack }
func (s *LifecycleService) Limits() *LimitTracker { return s.limits }
func (s *LifecycleService) Critic() *Critic { return s.critic }
func (s *LifecycleService) Shadow() *branching.ShadowWorkspace { return s.shadow }
func (s *LifecycleService) Reflector() *Reflector { return s.reflector }
func (s *LifecycleService) FewShotStore() *FewShotStore { return s.fewShotStore }
func (s *LifecycleService) AdaptivePrompt() *AdaptivePrompt { return s.adaptivePrompt }
func (s *LifecycleService) Activity() *memory.ActivityTracker { return s.activity }
func (s *LifecycleService) AgentsAccum() *prompts.AgentsAccumulator { return s.agentsAccum }
func (s *LifecycleService) ResponseCache() *ResponseCache { return s.responseCache }
func (s *LifecycleService) Pipeline() *IntegrationPipeline { return s.pipeline }
func (s *LifecycleService) Steering() *SteeringQueue { return s.steering }
func (s *LifecycleService) Lifecycle() *SessionLifecycle { return s.lifecycle }
109 changes: 109 additions & 0 deletions internal/engine/memory_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package engine

import (
"context"

"github.com/GrayCodeAI/hawk/internal/intelligence/memory"
"github.com/GrayCodeAI/hawk/internal/observability/logger"
"github.com/GrayCodeAI/hawk/internal/types"
)

// MemoryService is the Session's view of the memory layer: yaad bridge,
// recall/remember interface, enhanced-memory manager, sleeptime
// consolidation, skill distillation, file-mention detector, agents
// accumulator. Extracted from Session in Phase 4 of the god-object
// decomposition (see docs/session-decomposition.md).
//
// The interface boundary is small on purpose: every method either
// does or doesn't talk to yaad, and the agent loop's branching on
// nil is preserved.
type MemoryService struct {
// memory is the simple Recall/Remember interface.
memory MemoryRecaller
// yaad is the rich memory graph bridge.
yaad *memory.YaadBridge
// enhanced is the post-session memory manager.
enhanced *memory.EnhancedMemoryManager
// log is the session logger.
log *logger.Logger
}

// NewMemoryService constructs an empty MemoryService. Wire the
// optional collaborators via the With* setters.
func NewMemoryService(log *logger.Logger) *MemoryService {
if log == nil {
log = logger.Default()
}
return &MemoryService{log: log}
}

// WithMemory sets the simple MemoryRecaller.
func (s *MemoryService) WithMemory(m MemoryRecaller) *MemoryService {
s.memory = m
return s
}

// WithYaad sets the yaad bridge.
func (s *MemoryService) WithYaad(y *memory.YaadBridge) *MemoryService {
s.yaad = y
return s
}

// WithEnhanced sets the enhanced-memory manager.
func (s *MemoryService) WithEnhanced(e *memory.EnhancedMemoryManager) *MemoryService {
s.enhanced = e
return s
}

// RecallContext returns a string of relevant memories for the given
// lastUserMsg under the given token budget. Returns empty string if
// no memory is wired. Combines yaad recall + few-shot examples +
// user-preference learning into one shot.
func (s *MemoryService) RecallContext(_ context.Context, lastUserMsg string, budget int) string {
if s.yaad == nil {
return ""
}
out, err := s.yaad.Recall(lastUserMsg, budget)
if err != nil || out == "" {
return ""
}
return "## Relevant Memories\n" + out
}

// Remember stores a content+category pair in the memory layer.
// Best-effort: errors are logged but not returned (the agent loop
// shouldn't fail a turn just because yaad is unavailable).
func (s *MemoryService) Remember(ctx context.Context, content, category string) {
if s.enhanced != nil {
_ = s.enhanced.Remember(content, category)
return
}
if s.memory != nil {
_ = s.memory.Remember(content, category)
}
_ = ctx // reserved for future context-aware memory ops
}

// OnSessionEnd runs the post-session memory bookkeeping.
func (s *MemoryService) OnSessionEnd(success bool) {
if s.enhanced != nil {
s.enhanced.EndSession(success)
}
}

// Accessors.
func (s *MemoryService) Yaad() *memory.YaadBridge { return s.yaad }
func (s *MemoryService) Memory() MemoryRecaller { return s.memory }
func (s *MemoryService) Enhanced() *memory.EnhancedMemoryManager {
return s.enhanced
}

// IsZero reports whether the service has any memory wired.
func (s *MemoryService) IsZero() bool {
return s == nil || (s.memory == nil && s.yaad == nil && s.enhanced == nil)
}

// _ unused-import workaround: keep types referenced even when none
// of the methods actually destructure EyrieMessage directly. The
// agent loop reads s.messages via the persistence service.
var _ = (*types.EyrieMessage)(nil)
Loading
Loading