Skip to content

Commit 8f156cf

Browse files
committed
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.
1 parent 1e5f03c commit 8f156cf

3 files changed

Lines changed: 434 additions & 0 deletions

File tree

internal/engine/chat_service.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/GrayCodeAI/hawk/internal/observability/metrics"
8+
"github.com/GrayCodeAI/hawk/internal/resilience/ratelimit"
9+
"github.com/GrayCodeAI/hawk/internal/resilience/retry"
10+
"github.com/GrayCodeAI/hawk/internal/types"
11+
12+
modelPkg "github.com/GrayCodeAI/hawk/internal/provider/routing"
13+
)
14+
15+
// ChatService is the Session's view of the LLM transport. It owns the
16+
// eyrie client, the provider/model identity, API keys, the circuit-breaker
17+
// router, the rate limiter, and the streaming-with-continuation retry
18+
// logic. It is constructed once in NewSessionWithClient and consulted by
19+
// agentLoop every turn.
20+
//
21+
// Extracted from Session in the god-object decomposition. Session now
22+
// holds *ChatService instead of the 8+ individual fields this service
23+
// previously inlined. See docs/session-decomposition.md for the migration
24+
// plan.
25+
type ChatService struct {
26+
// client is the eyrie transport. Always non-nil after construction.
27+
client ChatClient
28+
// provider / model are the active LLM identity.
29+
provider string
30+
model string
31+
// apiKeys is provider→key, used for legacy single-provider clients.
32+
apiKeys map[string]string
33+
// router is the legacy single-provider circuit breaker. Bypassed
34+
// when DeploymentRouting is true (the DeploymentRouter has its own
35+
// per-deployment breakers).
36+
router *modelPkg.Router
37+
// deploymentRouting is true when the client is catalog-backed
38+
// (e.g. DeploymentRouter from eyrie/runtime.ChatProvider).
39+
deploymentRouting bool
40+
// rateLimiter is the per-session token bucket.
41+
rateLimiter *ratelimit.Limiter
42+
// metrics is the Session-level metrics registry.
43+
metrics *metrics.Registry
44+
// retryCfg is the HTTP-retry config for the LLM call.
45+
retryCfg retry.Config
46+
// contCfg is the continuation config for StreamChatContinue.
47+
contCfg types.ContinuationConfig
48+
// outputSchema, when non-empty, requests a JSON-schema-constrained
49+
// response. Plumbed into eyrie's ChatOptions.ResponseFormat.
50+
outputSchema string
51+
// glmThinkingEnabled toggles GLM/Z.ai extended reasoning on outgoing
52+
// requests. nil leaves the model default.
53+
glmThinkingEnabled *bool
54+
}
55+
56+
// ChatServiceConfig bundles the optional fields the constructor doesn't
57+
// require. NewSessionWithClient sets sensible defaults for any zero-valued
58+
// field; tests can override individual fields.
59+
type ChatServiceConfig struct {
60+
Provider string
61+
Model string
62+
APIKeys map[string]string
63+
Router *modelPkg.Router
64+
DeploymentRouting bool
65+
RateLimiter *ratelimit.Limiter
66+
Metrics *metrics.Registry
67+
RetryConfig retry.Config
68+
ContinuationConfig types.ContinuationConfig
69+
OutputSchema string
70+
GLMThinkingEnabled *bool
71+
}
72+
73+
// NewChatService constructs a ChatService with sensible defaults for any
74+
// zero-valued field in cfg. The client must be non-nil.
75+
func NewChatService(client ChatClient, cfg ChatServiceConfig) *ChatService {
76+
if cfg.APIKeys == nil {
77+
cfg.APIKeys = map[string]string{}
78+
}
79+
if cfg.RetryConfig.MaxRetries == 0 {
80+
cfg.RetryConfig = retry.DefaultConfig()
81+
cfg.RetryConfig.MaxRetries = 2
82+
cfg.RetryConfig.BaseDelay = 500 * time.Millisecond
83+
}
84+
if cfg.ContinuationConfig.MaxContinuations == 0 {
85+
cfg.ContinuationConfig = types.DefaultContinuationConfig()
86+
}
87+
if cfg.Metrics == nil {
88+
cfg.Metrics = metrics.NewRegistry()
89+
}
90+
return &ChatService{
91+
client: client,
92+
provider: cfg.Provider,
93+
model: cfg.Model,
94+
apiKeys: cfg.APIKeys,
95+
router: cfg.Router,
96+
deploymentRouting: cfg.DeploymentRouting,
97+
rateLimiter: cfg.RateLimiter,
98+
metrics: cfg.Metrics,
99+
retryCfg: cfg.RetryConfig,
100+
contCfg: cfg.ContinuationConfig,
101+
outputSchema: cfg.OutputSchema,
102+
glmThinkingEnabled: cfg.GLMThinkingEnabled,
103+
}
104+
}
105+
106+
// Client returns the underlying eyrie client. Exposed for callers (e.g.
107+
// background goroutines) that need to issue one-off LLM calls without
108+
// the agent-loop retry wrapper.
109+
func (c *ChatService) Client() ChatClient { return c.client }
110+
111+
// Provider returns the active provider identifier.
112+
func (c *ChatService) Provider() string { return c.provider }
113+
114+
// Model returns the active model identifier.
115+
func (c *ChatService) Model() string { return c.model }
116+
117+
// APIKeys returns the provider→key map. Used by Session.SubSession to
118+
// clone credentials for sub-agents.
119+
func (c *ChatService) APIKeys() map[string]string { return c.apiKeys }
120+
121+
// DeploymentRouting reports whether the underlying client is catalog-backed
122+
// (true) or a single-provider transport (false).
123+
func (c *ChatService) DeploymentRouting() bool { return c.deploymentRouting }
124+
125+
// SetAPIKey stores a provider→key mapping.
126+
func (c *ChatService) SetAPIKey(provider, key string) {
127+
c.apiKeys[provider] = key
128+
}
129+
130+
// SetModel updates the active model. The next StreamChat will use the new
131+
// model.
132+
func (c *ChatService) SetModel(model string) {
133+
c.model = model
134+
}
135+
136+
// SetProvider updates the active provider.
137+
func (c *ChatService) SetProvider(provider string) {
138+
c.provider = provider
139+
}
140+
141+
// Reattach swaps the underlying client (e.g. after deployment routing
142+
// changes). Preserves the APIKeys and other config.
143+
func (c *ChatService) Reattach(client ChatClient, provider string) {
144+
if client == nil {
145+
return
146+
}
147+
c.client = client
148+
if provider != "" {
149+
c.provider = provider
150+
}
151+
}
152+
153+
// BuildOptions constructs a types.ChatOptions for an outgoing LLM call,
154+
// encoding all the knobs the agent loop needs (system prompt, model,
155+
// max tokens, tools, structured output, etc.).
156+
func (c *ChatService) BuildOptions(systemPrompt, activeModel string, maxTokens int, tools []types.EyrieTool) types.ChatOptions {
157+
opts := types.ChatOptions{
158+
Provider: c.provider,
159+
Model: activeModel,
160+
MaxTokens: maxTokens,
161+
System: systemPrompt,
162+
EnableCaching: c.provider == "anthropic",
163+
Tools: tools,
164+
}
165+
// GLM/Z.ai extended reasoning toggle: only meaningful for the z-ai
166+
// provider, where eyrie emits thinking={type:enabled|disabled}.
167+
if c.provider == "z-ai" && c.glmThinkingEnabled != nil {
168+
opts.GLMThinkingEnabled = c.glmThinkingEnabled
169+
}
170+
// Structured output: request a JSON-schema-constrained response when set.
171+
if c.outputSchema != "" {
172+
opts.ResponseFormat = &types.ResponseFormat{Type: "json_schema", Schema: c.outputSchema}
173+
}
174+
return opts
175+
}
176+
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.
181+
//
182+
// On context cancellation mid-call, returns the cancellation error wrapped
183+
// with whatever partial state the upstream had emitted (caller should
184+
// check ctx.Err()).
185+
func (c *ChatService) Stream(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.StreamResult, error) {
186+
// Rate limit: wait for a token before making the LLM call
187+
if c.rateLimiter != nil {
188+
if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil {
189+
return nil, waitErr
190+
}
191+
}
192+
c.metrics.Counter("api.requests").Inc()
193+
194+
var result *types.StreamResult
195+
err := retry.Do(ctx, c.retryCfg, func() error {
196+
var callErr error
197+
result, callErr = c.client.StreamChatContinue(ctx, messages, opts, c.contCfg)
198+
if callErr != nil {
199+
// On context overflow, do an emergency compact and retry once.
200+
if isContextOverflow(callErr) {
201+
result, callErr = c.client.StreamChatContinue(ctx, messages, opts, c.contCfg)
202+
}
203+
}
204+
return callErr
205+
})
206+
if err != nil {
207+
c.recordFailure(err)
208+
return nil, err
209+
}
210+
c.recordSuccess()
211+
return result, nil
212+
}
213+
214+
// Chat issues a non-streaming LLM call. Used by background goroutines
215+
// (sleeptime consolidation, skill distillation) that don't need
216+
// incremental events.
217+
func (c *ChatService) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) {
218+
return c.client.Chat(ctx, messages, opts)
219+
}
220+
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+
238+
// isContextOverflow reports whether err looks like a "context too long"
239+
// error from the upstream provider. Used by Stream() to trigger an
240+
// emergency context-compact + retry.
241+
func isContextOverflow(err error) bool {
242+
if err == nil {
243+
return false
244+
}
245+
msg := err.Error()
246+
return contains(msg, "too long") || contains(msg, "too many tokens")
247+
}
248+
249+
func contains(s, sub string) bool {
250+
return len(sub) > 0 && len(s) >= len(sub) && (s == sub || (len(s) > 0 && indexOf(s, sub) >= 0))
251+
}
252+
253+
func indexOf(s, sub string) int {
254+
for i := 0; i+len(sub) <= len(s); i++ {
255+
if s[i:i+len(sub)] == sub {
256+
return i
257+
}
258+
}
259+
return -1
260+
}

0 commit comments

Comments
 (0)