diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a75ce5bb..9cc02e44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,8 @@ jobs: run: bash ./scripts/check-internal-layer-imports.sh - name: eyrie client boundary guard run: bash ./scripts/check-eyrie-client-imports.sh + - name: eyrie engine facade boundary guard + run: bash ./scripts/check-eyrie-engine-boundary.sh # ------------------------------------------------------------------------- # 2. Module hygiene — tidy, verify (hawk + external ecosystem repos via go.work). diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7f98fe10..9a0561bb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -81,7 +81,7 @@ jobs: output: trivy-image.sarif severity: CRITICAL,HIGH ignore-unfixed: true - exit-code: '0' + exit-code: '1' - name: Build and push uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 diff --git a/.gitignore b/.gitignore index d9fbe713..cf565ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ hawk_bin .gocache-*/ .gomodcache/ .gomodcache-*/ +.golangci-cache/ .gopath/ .tmp/ *.codegraph.db diff --git a/Dockerfile b/Dockerfile index 2d8fa824..9a701956 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,7 @@ RUN rm -f go.work go.work.sum && \ -o hawk ./cmd/hawk # Runtime stage — Alpine (hawk requires git + bash for workspace operations; distroless excluded) -FROM alpine:3.21 +FROM alpine:3.23 RUN apk add --no-cache ca-certificates git bash tini && \ adduser -D -u 1000 -h /home/hawk hawk diff --git a/Makefile b/Makefile index 897dcd9c..6c277a7d 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ GORELEASER := $(GOBIN_DIR)/goreleaser # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- -.PHONY: all bench boundaries build ci clean contracts-guard ecosystem-guard eyrie-client-guard peer-guard internal-layers-guard submodule-release-parity cover cover-new fmt help install lint lint-fix \ +.PHONY: all bench boundaries build ci clean contracts-guard ecosystem-guard eyrie-client-guard eyrie-engine-guard peer-guard internal-layers-guard submodule-release-parity cover cover-new fmt help install lint lint-fix \ release security setup smoke path sync-external test test-10x test-live test-new test-race tidy version vet # --------------------------------------------------------------------------- @@ -104,16 +104,19 @@ contracts-guard: ## Fail on any legacy imports of removed hawk/shared/types. ecosystem-guard: ## Fail if external ecosystem repos import hawk/internal or removed hawk/shared/types. bash ./scripts/check-ecosystem-boundaries.sh -eyrie-client-guard: ## Fail on new direct eyrie/client imports outside Hawk transport adapters. +eyrie-client-guard: ## Fail on any production eyrie/client import. bash ./scripts/check-eyrie-client-imports.sh +eyrie-engine-guard: ## Require all production Eyrie imports to use the stable engine facade. + bash ./scripts/check-eyrie-engine-boundary.sh + peer-guard: ## Fail if support engines import each other instead of depending only on Hawk contracts. bash ./scripts/check-support-repo-coupling.sh internal-layers-guard: ## Enforce one-way dependencies across stable Hawk internal layers. bash ./scripts/check-internal-layer-imports.sh -boundaries: contracts-guard ecosystem-guard eyrie-client-guard peer-guard internal-layers-guard ## Alias for all boundary guards (matches `make boundaries` in engine repos). +boundaries: contracts-guard ecosystem-guard eyrie-client-guard eyrie-engine-guard peer-guard internal-layers-guard ## Alias for all boundary guards (matches `make boundaries` in engine repos). submodule-release-parity: ## Verify every go.mod ecosystem version resolves to its pinned Gitlink. bash ./scripts/check-submodule-release-parity.sh diff --git a/api/openapi.yaml b/api/openapi.yaml index 5ef4bf56..43566705 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -43,7 +43,9 @@ components: description: The user prompt to send to the agent session_id: type: string - description: Reserved for session continuation (currently ignored; every chat creates a fresh engine session) + maxLength: 128 + pattern: '^[A-Za-z0-9._-]+$' + description: Durable session ID to continue. The session must already exist; successful continuation returns the same ID. model: type: string description: Optional model override @@ -55,10 +57,10 @@ components: description: Autonomy preset controlling non-interactive permission approval cwd: type: string - description: Working directory for the agent session + description: Existing directory associated with session metadata. The daemon executes tools from its startup working directory; it never performs a process-wide chdir per request. agent: type: string - description: Named agent persona to run + description: Named Hawk agent persona to apply. Continuations inherit the persisted persona when omitted. ChatResponse: type: object @@ -79,7 +81,7 @@ components: Session: type: object - description: Lightweight in-memory record of a daemon chat session. + description: Lightweight active record for a durably persisted daemon chat session. properties: id: type: string @@ -93,6 +95,8 @@ components: type: integer cwd: type: string + agent: + type: string SessionDetail: type: object @@ -110,6 +114,8 @@ components: type: string provider: type: string + agent: + type: string cwd: type: string name: @@ -277,8 +283,9 @@ paths: tags: [system] summary: Readiness probe description: | - Returns 200 when the daemon can serve chat (engine configured), - 503 with a reason otherwise. + Returns 200 only when a session factory is configured and Eyrie's local + preflight confirms provider state, catalog, credentials, and model + selection are ready. Returns 503 with the failed dependency otherwise. security: [] responses: "200": @@ -301,8 +308,10 @@ paths: description: | Non-streaming: returns the full response in one JSON object. Streaming: send `Accept: text/event-stream` to receive Server-Sent Events. - Each request creates a new agent session; there is no standalone - session-creation endpoint. + Omit `session_id` to create and durably persist a new session. Supply a + previously returned `session_id` to load its transcript, append a turn, + and update that same durable session. The returned JSON field and the + `X-Hawk-Session-ID` header identify a retrievable `/v1/sessions/{id}`. requestBody: required: true content: @@ -312,6 +321,11 @@ paths: responses: "200": description: Agent response (or SSE stream) + headers: + X-Hawk-Session-ID: + description: Durable session ID created or continued by this request. + schema: + type: string content: application/json: schema: @@ -331,6 +345,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "404": + description: Requested continuation session does not exist + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "503": description: Engine not configured content: diff --git a/cmd/chat.go b/cmd/chat.go index 6397321a..821901bc 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -22,8 +22,6 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/eyrie/runtime" - "github.com/GrayCodeAI/eyrie/storage" "github.com/GrayCodeAI/hawk/internal/bridge/sessioncapture" "github.com/GrayCodeAI/hawk/internal/codegraph" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" @@ -169,9 +167,9 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco // Initialize conversation DAG for branching support startup.MarkPhase("newChatModel:dag") - dagPath := filepath.Join(hawkstorage.SessionsDir(), "convo.db") - if dag, err := storage.NewDAG(dagPath, sid); err == nil { - sess.SetConvoDAG(dag) + graphPath := filepath.Join(hawkstorage.SessionsDir(), "conversations", sid+".json") + if graph, err := session.OpenConversationGraph(graphPath, sid); err == nil { + sess.SetConversationGraph(graph) } startup.EndPhase("newChatModel:dag") @@ -268,7 +266,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco // Prefetch live models for the active provider so footer ctx/pricing stay current. go func() { providerName := effectiveProvider - entries, _ := runtime.ListModels(context.Background(), runtime.ListModelsOpts{ProviderID: providerName, Source: runtime.ListSourceAuto}) + entries, _ := hawkconfig.ListEngineModels(context.Background(), providerName, false) opts := configModelOptionsFromEyrie(entries) if len(opts) > 0 { modelCacheMu.Lock() @@ -380,10 +378,6 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco } }(m) - go func() { - _ = hawkconfig.CompiledCatalogV1() - }() - // Load plugins/skills after startup and refresh welcome indicators when ready. go func() { runtime := plugin.NewRuntime() diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index 055deb19..691f9950 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -10,7 +10,6 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/multiagent/parallel" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -410,9 +409,9 @@ func (m *chatModel) handleParallelCommand(parts []string, text string) (tea.Mode } taskIdx++ - // Create a new session for this agent with same provider/model - agentSession := engine.NewSession( - m.session.Provider(), + // Clone the engine-backed transport so parallel agents cannot bypass + // the parent session's resolved gateway policy. + agentSession := m.session.SubSession( m.session.Model(), "You are a coding agent working in an isolated git worktree. Complete the assigned task.", m.registry, diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index 016c6ff6..1c113d6f 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -175,7 +175,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/fork": // If convodag is active, fork from the current head node - if m.session.Persistence().DAG() != nil { + if m.session.Persistence().Graph() != nil { headID := m.session.ConvoHead() if headID == "" { m.messages = append(m.messages, displayMsg{role: "error", content: "No conversation to fork from."}) diff --git a/cmd/chat_config_deployment.go b/cmd/chat_config_deployment.go index 2265aed4..47cc52b7 100644 --- a/cmd/chat_config_deployment.go +++ b/cmd/chat_config_deployment.go @@ -9,8 +9,6 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/ui/icons" @@ -48,22 +46,13 @@ func firstRunModelProvider(m chatModel) string { return "" } -func credentialOptionFromHawk(in hawkconfig.CredentialInference) runtime.CredentialProviderOption { - return runtime.CredentialProviderOption{ - ProviderID: in.ProviderID, - DeploymentID: in.DeploymentID, - EnvVar: in.EnvVar, - DisplayName: in.DisplayName, - } -} - func saveProviderKeyAsync(inference hawkconfig.CredentialInference, secret string) tea.Cmd { return saveCredentialAsync(inference, secret) } func saveOllamaAsync(baseURL string) tea.Cmd { return func() tea.Msg { - inference, err := runtime.LocalCredentialInference(configProviderOllama) + inference, err := hawkconfig.LocalCredentialInference(configProviderOllama) if err != nil { return configApplyCredentialsMsg{err: err} } @@ -80,14 +69,7 @@ func saveOllamaAsync(baseURL string) tea.Cmd { func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string) tea.Cmd { return func() tea.Msg { ctx := context.Background() - if inference.ProviderID == hawkconfig.ProviderXiaomiTokenPlan { - hawkconfig.ApplyXiaomiTokenPlanRegionEnv(ctx) - } - if inference.ProviderID == hawkconfig.ProviderZAICoding { - hawkconfig.ApplyZAIRegionEnv(ctx) - } - rtInf := config.InferenceFromOption(credentialOptionFromHawk(inference)) - if err := runtime.SaveCredential(ctx, rtInf, secret); err != nil { + if err := hawkconfig.SaveCredential(ctx, inference, secret); err != nil { return configApplyCredentialsMsg{ err: err, providerID: inference.ProviderID, @@ -105,7 +87,7 @@ func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string } } - entries, listErr := runtime.ListModels(ctx, runtime.ListModelsOpts{ProviderID: inference.ProviderID, Source: runtime.ListSourceAuto}) + entries, listErr := hawkconfig.ListEngineModels(ctx, inference.ProviderID, false) if listErr != nil { return configApplyCredentialsMsg{ err: listErr, @@ -114,11 +96,6 @@ func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string } } opts := configModelOptionsFromEyrie(entries) - if len(opts) == 0 && result.Setup != nil { - fallback := hawkconfig.OptionsFromSetupUI(result.Setup, inference.ProviderID) - opts = toConfigModelOptionsFromHawk(fallback) - } - return configApplyCredentialsMsg{ summary: hawkconfig.FormatApplyCredentialsSummary(result), providerID: inference.ProviderID, @@ -128,17 +105,6 @@ func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string } } -func toConfigModelOptionsFromHawk(in []hawkconfig.ModelOption) []configModelOption { - out := make([]configModelOption, len(in)) - for i, o := range in { - out[i] = configModelOption{ - ID: o.ID, - DisplayName: o.DisplayName, - } - } - return out -} - func (m chatModel) startConfigOllamaURL() (chatModel, tea.Cmd) { return m.startConfigOllamaURLWithValue(configDefaultOllamaURL) } @@ -249,11 +215,11 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg } func (m chatModel) rebuildSessionTransport() (chatModel, tea.Cmd) { - selection := runtime.EffectiveSelection(context.Background(), runtime.SelectionOpts{ + selection := hawkconfig.EffectiveSelectionWithSettings(context.Background(), m.settings, hawkconfig.SelectionOptions{ ProviderOverride: firstNonEmptyTrimmed(m.session.Provider(), m.settings.Provider), ModelOverride: firstNonEmptyTrimmed(m.session.Model(), m.settings.Model), }) - if err := engine.RebuildSessionTransport(context.Background(), m.session, selection, m.session.Provider()); err != nil { + if err := engine.RebuildSessionTransportForSettings(context.Background(), m.settings, m.session, selection, m.session.Provider()); err != nil { m.configNotice = sanitizeConfigNotice(err.Error()) } syncSessionFromPersistedSelection(m.session) diff --git a/cmd/chat_config_gateways_test.go b/cmd/chat_config_gateways_test.go index cba395f7..78f9d69b 100644 --- a/cmd/chat_config_gateways_test.go +++ b/cmd/chat_config_gateways_test.go @@ -20,6 +20,7 @@ func chatModelForConfigPasteTest() chatModel { } func TestConfigGatewaysView_RequiresKeyForModelCounts(t *testing.T) { + isolateCredentialHome(t) hawkconfig.InvalidateConfigUICache() store := &credentials.MapStore{} credentials.SetDefaultStore(store) diff --git a/cmd/chat_config_hub.go b/cmd/chat_config_hub.go index d27905c8..58ca7431 100644 --- a/cmd/chat_config_hub.go +++ b/cmd/chat_config_hub.go @@ -5,7 +5,6 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/catalog/registry" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -70,12 +69,7 @@ func catalogPricesAreStale(opts []configModelOption) bool { } func providerHasLiveFetcher(providerID string) bool { - for _, key := range registry.LiveFetcherKeys() { - if key == providerID { - return true - } - } - return false + return hawkconfig.GatewaySupportsLiveDiscovery(providerID) } func (m chatModel) returnToOllamaURLAfterError(err error) (chatModel, tea.Cmd) { diff --git a/cmd/chat_config_keys.go b/cmd/chat_config_keys.go index 8197a216..df9b4712 100644 --- a/cmd/chat_config_keys.go +++ b/cmd/chat_config_keys.go @@ -7,12 +7,11 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func credentialsStoreLabel() string { - return credentials.PlatformSecretStoreName() + return hawkconfig.CredentialStoreName() } func configGatewayRemovePrompt(step int, gatewayName string) string { diff --git a/cmd/chat_config_models.go b/cmd/chat_config_models.go index 6f06cb2c..cd33a003 100644 --- a/cmd/chat_config_models.go +++ b/cmd/chat_config_models.go @@ -7,8 +7,6 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" ) @@ -16,6 +14,9 @@ import ( // configModelOption is one row in the /config model picker (display from eyrie, id for settings). type configModelOption struct { ID string + CanonicalID string + ProviderID string + GatewayID string DisplayName string Owner string ContextWindow int @@ -62,11 +63,11 @@ func fetchModelsAsync(provider string) tea.Cmd { if provider == "" { provider = hawkconfig.DefaultModelProviderFilter(ctx) } - entries, err := runtime.ListModels(ctx, runtime.ListModelsOpts{ProviderID: provider, Source: runtime.ListSourceAuto}) + entries, err := hawkconfig.ListEngineModels(ctx, provider, false) if err != nil { - if _, derr := runtime.Discover(ctx); derr == nil { + if _, derr := hawkconfig.ListEngineModels(ctx, provider, true); derr == nil { InvalidateModelCacheProvider(provider) - entries, err = runtime.ListModels(ctx, runtime.ListModelsOpts{ProviderID: provider, Source: runtime.ListSourceAuto}) + entries, err = hawkconfig.ListEngineModels(ctx, provider, false) } } if err != nil { @@ -82,59 +83,25 @@ func fetchModelsAsync(provider string) tea.Cmd { } } -func configModelOptionsFromEyrie(entries []runtime.ModelEntry) []configModelOption { +func configModelOptionsFromEyrie(entries []hawkconfig.EngineModel) []configModelOption { opts := make([]configModelOption, len(entries)) for i, e := range entries { opts[i] = configModelOption{ ID: e.ID, - DisplayName: catalog.DisplayModelLabel(e.ID, e.DisplayName), - Owner: catalog.DisplayModelOwner(e.Owner, e.ID), + CanonicalID: e.CanonicalID, + ProviderID: e.ProviderID, + GatewayID: e.GatewayID, + DisplayName: e.DisplayName, + Owner: e.Owner, ContextWindow: e.ContextWindow, InputPricePer1M: e.InputPricePer1M, OutputPricePer1M: e.OutputPricePer1M, - PriceKnown: modelOptionPriceKnown(e.ID, e.DisplayName, e.InputPricePer1M, e.OutputPricePer1M, e.ContextWindow), + PriceKnown: e.PriceKnown, } } return opts } -func configModelOptionsFromCatalog(entries []catalog.ModelCatalogEntry) []configModelOption { - opts := make([]configModelOption, len(entries)) - for i, e := range entries { - opts[i] = configModelOption{ - ID: e.ID, - DisplayName: catalog.DisplayModelLabel(e.ID, e.DisplayName), - Owner: catalog.DisplayModelOwner(e.Owner, e.ID, e.LiveMetadata), - ContextWindow: e.ContextWindow, - InputPricePer1M: e.InputPricePer1M, - OutputPricePer1M: e.OutputPricePer1M, - PriceKnown: modelOptionPriceKnown(e.ID, e.DisplayName, e.InputPricePer1M, e.OutputPricePer1M, e.ContextWindow), - } - } - return opts -} - -func modelOptionPriceKnown(id, displayName string, input, output float64, contextWindow int) bool { - if input > 0 || output > 0 { - return true - } - if modelOptionLooksExplicitlyFree(id, displayName) { - return true - } - // OpenAI-compatible /models endpoints often omit pricing fields. If the - // catalog also carries context, a zero price is intentional catalog metadata; - // when both price and context are absent, keep price unknown. - return contextWindow > 0 -} - -func modelOptionLooksExplicitlyFree(id, displayName string) bool { - text := strings.ToLower(strings.TrimSpace(id) + " " + strings.TrimSpace(displayName)) - return strings.Contains(text, ":free") || - strings.Contains(text, "/free") || - strings.HasSuffix(text, "-free") || - strings.Contains(text, " free") -} - func filterConfigModelOptions(opts []configModelOption, query string) []configModelOption { query = strings.TrimSpace(strings.ToLower(query)) if query == "" { @@ -198,10 +165,10 @@ func ensureModelCacheLoaded(provider string) { modelSyncMu.Unlock() ctx := context.Background() - entries, err := runtime.ListModels(ctx, runtime.ListModelsOpts{ProviderID: provider, Source: runtime.ListSourceCache}) + entries, err := hawkconfig.ListEngineModels(ctx, provider, false) if err != nil { - if _, derr := runtime.Discover(ctx); derr == nil { - entries, err = runtime.ListModels(ctx, runtime.ListModelsOpts{ProviderID: provider, Source: runtime.ListSourceCache}) + if _, derr := hawkconfig.ListEngineModels(ctx, provider, true); derr == nil { + entries, err = hawkconfig.ListEngineModels(ctx, provider, false) } } if err != nil || len(entries) == 0 { @@ -280,15 +247,13 @@ func loadConfigModelOptions(provider string) []configModelOption { return cached } modelCacheMu.RUnlock() - if compiled := hawkconfig.CompiledCatalogV1(); compiled != nil { - entries := catalog.ModelEntriesForProvider(compiled, provider) - if len(entries) > 0 { - opts := configModelOptionsFromCatalog(entries) - modelCacheMu.Lock() - modelCache[provider] = opts - modelCacheMu.Unlock() - return opts - } + entries, err := hawkconfig.ListEngineModels(context.Background(), provider, false) + if err == nil && len(entries) > 0 { + opts := configModelOptionsFromEyrie(entries) + modelCacheMu.Lock() + modelCache[provider] = opts + modelCacheMu.Unlock() + return opts } return nil } diff --git a/cmd/chat_config_models_test.go b/cmd/chat_config_models_test.go index 59bbfe5c..4ef06740 100644 --- a/cmd/chat_config_models_test.go +++ b/cmd/chat_config_models_test.go @@ -1,6 +1,10 @@ package cmd -import "testing" +import ( + "testing" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) func TestFilterConfigModelOptions(t *testing.T) { opts := []configModelOption{ @@ -39,3 +43,17 @@ func TestModelOptionIsActive(t *testing.T) { t.Fatal("expected no match") } } + +func TestConfigModelOptionsCarryResolvedEngineIdentity(t *testing.T) { + opts := configModelOptionsFromEyrie([]hawkconfig.EngineModel{{ + ID: "models/gemini-pro", CanonicalID: "google/gemini-pro", + ProviderID: "google", GatewayID: "gemini", + }}) + if len(opts) != 1 || opts[0].CanonicalID != "google/gemini-pro" || + opts[0].ProviderID != "google" || opts[0].GatewayID != "gemini" { + t.Fatalf("resolved engine identity was lost: %+v", opts) + } + if !modelOptionIsActiveResolved(opts[0], "google/gemini-pro", "google/gemini-pro") { + t.Fatal("canonical identity did not match without catalog lookup") + } +} diff --git a/cmd/chat_config_panel.go b/cmd/chat_config_panel.go index ccd79c1b..7c656a2e 100644 --- a/cmd/chat_config_panel.go +++ b/cmd/chat_config_panel.go @@ -9,7 +9,6 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -25,7 +24,7 @@ func configModelChoices(opts []configModelOption, showProvider bool) []string { label = shortModelID(opt.ID) } if showProvider { - if prov := hawkconfig.ProviderOfModel(opt.ID); prov != "" { + if prov := strings.TrimSpace(opt.ProviderID); prov != "" { label = fmt.Sprintf("%-28s %s", label, prov) } } @@ -275,6 +274,10 @@ func (m chatModel) configActiveModelID() string { } func modelOptionIsActive(opt configModelOption, activeModelID string) bool { + return modelOptionIsActiveResolved(opt, activeModelID, hawkconfig.CanonicalModelID(context.Background(), activeModelID)) +} + +func modelOptionIsActiveResolved(opt configModelOption, activeModelID, activeCanonicalID string) bool { activeModelID = strings.TrimSpace(activeModelID) if activeModelID == "" { return false @@ -282,12 +285,10 @@ func modelOptionIsActive(opt configModelOption, activeModelID string) bool { if strings.EqualFold(strings.TrimSpace(opt.ID), activeModelID) { return true } - if compiled := hawkconfig.CompiledCatalogV1(); compiled != nil { - optCanon, optOK := compiled.CanonicalModelForAliasOrID(opt.ID) - activeCanon, activeOK := compiled.CanonicalModelForAliasOrID(activeModelID) - if optOK && activeOK && optCanon == activeCanon { - return true - } + optCanonical := strings.TrimSpace(opt.CanonicalID) + activeCanonicalID = strings.TrimSpace(activeCanonicalID) + if optCanonical != "" && optCanonical == activeCanonicalID { + return true } return false } @@ -300,8 +301,9 @@ func (m chatModel) focusConfigActiveModelSelection() chatModel { return m } activeID := m.configActiveModelID() + activeCanonicalID := hawkconfig.CanonicalModelID(context.Background(), activeID) for i, opt := range opts { - if modelOptionIsActive(opt, activeID) { + if modelOptionIsActiveResolved(opt, activeID, activeCanonicalID) { m.configSel = i if m.configSel < configWindowSize { m.configScroll = 0 @@ -339,6 +341,7 @@ func (m chatModel) configModelsBody() string { total := len(opts) allTotal := len(m.configModelOptions) activeModelID := m.configActiveModelID() + activeCanonicalID := hawkconfig.CanonicalModelID(context.Background(), activeModelID) if m.configSel < m.configScroll { m.configScroll = m.configSel @@ -355,7 +358,7 @@ func (m chatModel) configModelsBody() string { visible := make([]modelTableRow, 0, end-m.configScroll) for i := m.configScroll; i < end; i++ { row := modelTableRowFromOption(opts[i]) - row.Active = modelOptionIsActive(opts[i], activeModelID) + row.Active = modelOptionIsActiveResolved(opts[i], activeModelID, activeCanonicalID) visible = append(visible, row) } layout := computeModelTableLayout(m.configPanelViewWidth(), visible) @@ -391,7 +394,7 @@ func (m chatModel) configModelsBody() string { for i := m.configScroll; i < end; i++ { row := modelTableRowFromOption(opts[i]) - row.Active = modelOptionIsActive(opts[i], activeModelID) + row.Active = modelOptionIsActiveResolved(opts[i], activeModelID, activeCanonicalID) cursor := i == m.configSel b.WriteString(renderModelTableRow(row, cursor, row.Active, layout, rowStyle, cursorStyle, activeStyle, metaStyle, freeStyle) + "\n") } @@ -531,9 +534,7 @@ func (m chatModel) finishConfigEntry() (chatModel, tea.Cmd) { m.configPostSaveKeysProvider = providerName m.configSaving = true notice := fmt.Sprintf("Validating key for %s…", inference.DisplayName) - if hint := xiaomi.KeyMismatchHint(xiaomi.BillingTokenPlan, value); providerName == hawkconfig.ProviderXiaomiTokenPlan && hint != "" { - notice = hint + " · " + notice - } else if hint := xiaomi.KeyMismatchHint(xiaomi.BillingPayAsYouGo, value); providerName == xiaomi.ProviderPayAsYouGo && hint != "" { + if hint := hawkconfig.CredentialGuidance(providerName, value); hint != "" { notice = hint + " · " + notice } m.configNotice = notice @@ -805,7 +806,8 @@ func (m chatModel) selectConfigModelFromOptions(opts []configModelOption) (chatM if m.configSel < 0 || m.configSel >= len(opts) { return m, nil } - modelID := opts[m.configSel].ID + selected := opts[m.configSel] + modelID := selected.ID if err := hawkconfig.SetGlobalSetting("model", modelID); err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m.closeConfigPanel(), nil @@ -813,7 +815,9 @@ func (m chatModel) selectConfigModelFromOptions(opts []configModelOption) (chatM m.session.SetModel(modelID) if gw := strings.TrimSpace(m.configModelProvider); gw != "" { _ = hawkconfig.SetGlobalSetting("provider", gw) - } else if prov := hawkconfig.ProviderOfModel(modelID); prov != "" { + } else if gw := strings.TrimSpace(selected.GatewayID); gw != "" { + _ = hawkconfig.SetGlobalSetting("provider", gw) + } else if prov := strings.TrimSpace(selected.ProviderID); prov != "" { _ = hawkconfig.SetGlobalSetting("provider", prov) } m.syncSessionSelection() diff --git a/cmd/chat_multiturn_e2e_test.go b/cmd/chat_multiturn_e2e_test.go index d5ddccdc..59310ae8 100644 --- a/cmd/chat_multiturn_e2e_test.go +++ b/cmd/chat_multiturn_e2e_test.go @@ -30,7 +30,7 @@ func configureReadyChatState(t *testing.T) { if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { t.Fatal(err) } - if err := hawkconfig.SetActiveModel(ctx, "openrouter/auto"); err != nil { + if err := hawkconfig.SetActiveModel(ctx, "gpt-4o"); err != nil { t.Fatal(err) } hawkconfig.InvalidateConfigUICache() @@ -72,8 +72,8 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { if got := m.session.Provider(); got != "openrouter" { t.Fatalf("session provider = %q, want openrouter", got) } - if got := m.session.Model(); got != "openrouter/auto" { - t.Fatalf("session model = %q, want openrouter/auto", got) + if got := m.session.Model(); got != "openai/gpt-4o" { + t.Fatalf("session model = %q, want openai/gpt-4o", got) } if count := countMessagesByRole(m.messages, "user"); count != 1 { t.Fatalf("user message count after first enter = %d, want 1", count) diff --git a/cmd/chat_platform_ctx.go b/cmd/chat_platform_ctx.go index 5b0e7cf8..0ced9846 100644 --- a/cmd/chat_platform_ctx.go +++ b/cmd/chat_platform_ctx.go @@ -2,12 +2,13 @@ package cmd import ( "context" + "fmt" "strings" "sync" "time" tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) var platformCtxCache struct { @@ -29,7 +30,7 @@ func isXiaomiMimoProvider(provider string) bool { // platformContextForNativeModel reads the public MiMo platform catalog (no API key) from cache. func platformContextForNativeModel(modelID string) int { - modelID = xiaomi.NativeModelID(strings.TrimSpace(modelID)) + modelID = nativeModelID(modelID) if modelID == "" { return 0 } @@ -50,20 +51,31 @@ type platformContextIndexMsg struct { func fetchPlatformContextIndexCmd() tea.Cmd { return func() tea.Msg { - idx, err := xiaomi.FetchPlatformModelsIndex(context.Background(), "") + models, err := hawkconfig.ListPublicEngineModels(context.Background(), "xiaomi_mimo_payg") if err != nil { return platformContextIndexMsg{err: err} } - m := make(map[string]int, len(idx)) - for k, pm := range idx { - if pm.ContextLength > 0 { - m[xiaomi.NativeModelID(k)] = pm.ContextLength + m := make(map[string]int, len(models)) + for _, model := range models { + if model.ContextWindow > 0 { + m[nativeModelID(model.ID)] = model.ContextWindow } } + if len(m) == 0 { + return platformContextIndexMsg{err: fmt.Errorf("no Xiaomi model metadata available")} + } return platformContextIndexMsg{idx: m} } } +func nativeModelID(id string) string { + id = strings.TrimSpace(id) + if index := strings.LastIndex(id, "/"); index >= 0 { + return id[index+1:] + } + return id +} + func updatePlatformContextCache(msg platformContextIndexMsg) { platformCtxCache.mu.Lock() defer platformCtxCache.mu.Unlock() diff --git a/cmd/chat_subcommand_branches.go b/cmd/chat_subcommand_branches.go index 8d48cb03..bacf675c 100644 --- a/cmd/chat_subcommand_branches.go +++ b/cmd/chat_subcommand_branches.go @@ -14,7 +14,7 @@ func (b *branchesSubcommand) Aliases() []string { return nil } func (b *branchesSubcommand) Description() string { return "list conversation DAG branches" } func (b *branchesSubcommand) Usage() string { return "" } func (b *branchesSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { - if m.session.Persistence().DAG() == nil { + if m.session.Persistence().Graph() == nil { m.messages = append(m.messages, displayMsg{role: "system", content: "No conversation branches (DAG not active)."}) return m, nil } diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 5cefb756..bcdc3e0a 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -8,14 +8,10 @@ import ( "github.com/mattn/go-runewidth" - "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/credentials" - "github.com/GrayCodeAI/eyrie/setup" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/tool" - "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -297,24 +293,18 @@ func envSummary(provider, model string) string { } func envSummaryWithSelection(provider, model string, includeSelection bool) string { - compiled, _ := setup.LoadCompiledCatalog(context.Background()) - var envKeys []string - if compiled != nil { - envKeys = catalog.DiscoveryEnvKeysFromCatalog(compiled) + var providers []string + for _, gateway := range hawkconfig.GatewayStatuses(context.Background(), provider, model) { + providers = append(providers, gateway.ID) } - sort.Strings(envKeys) + sort.Strings(providers) var b strings.Builder if includeSelection { b.WriteString(fmt.Sprintf("Provider: %s\nModel: %s\n\n", provider, model)) } - b.WriteString(fmt.Sprintf("Credentials (%s):\n", credentials.PlatformSecretStoreName())) - ctx := context.Background() - for _, key := range envKeys { - status := "missing" - if credentials.HasSecret(ctx, key) { - status = "set" - } - b.WriteString(fmt.Sprintf(" %s: %s\n", key, status)) + b.WriteString(fmt.Sprintf("Credentials (%s):\n", hawkconfig.CredentialStoreName())) + for _, providerID := range providers { + b.WriteString(fmt.Sprintf(" %s: %s\n", providerID, hawkconfig.EnvKeyStatus(providerID))) } return strings.TrimRight(b.String(), "\n") } @@ -338,7 +328,7 @@ Model catalog and routing live in eyrie — hawk is the UI only.`, providerName, } func apiKeyConfigSummary() string { - return "API keys (" + credentials.PlatformSecretStoreName() + ")\n" + indentedAPIKeyLines() + return "API keys (" + hawkconfig.CredentialStoreName() + ")\n" + indentedAPIKeyLines() } func configuredKeyList() string { @@ -364,7 +354,7 @@ func indentedAPIKeyLines() string { } func apiKeyStatusLines() []string { - providers := types.NewClient(nil).GetProviders() + providers := hawkconfig.AllSetupGateways() sort.Strings(providers) var lines []string for _, provider := range providers { diff --git a/cmd/completions.go b/cmd/completions.go index 1cbfa80f..9d58b4cd 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/GrayCodeAI/eyrie/catalog/registry" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -63,11 +63,7 @@ func (g *CompletionGenerator) populateFlags() { } func (g *CompletionGenerator) populateProviders() { - providers := registry.All() - g.Providers = make([]string, 0, len(providers)) - for _, provider := range providers { - g.Providers = append(g.Providers, provider.ProviderID) - } + g.Providers = hawkconfig.AllSetupGateways() } func (g *CompletionGenerator) populateModels() { diff --git a/cmd/credentials.go b/cmd/credentials.go index 5530955a..d9f7b3eb 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/spf13/cobra" ) @@ -20,7 +19,6 @@ var credentialsStatusCmd = &cobra.Command{ Short: "Show where API keys are stored", RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - hawkconfig.PrepareCredentialDiscovery(ctx) cmd.Println(hawkconfig.FormatCredentialCLIStatus(ctx)) return nil }, @@ -36,7 +34,7 @@ var credentialsRemoveCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Removed %d key(s) from %s: %s\n", len(removed), credentials.PlatformSecretStoreName(), strings.Join(removed, ", ")) + cmd.Printf("Removed %d key(s) from %s: %s\n", len(removed), hawkconfig.CredentialStoreName(), strings.Join(removed, ", ")) return nil }, } @@ -46,18 +44,18 @@ var credentialsMigrateCmd = &cobra.Command{ Short: "Import legacy plaintext credential files into the OS secret store", RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - ok, detail := credentials.KeychainWriteAvailable(ctx) - if !ok { - return fmt.Errorf("cannot migrate: %s", detail) + storage := hawkconfig.CredentialStorageStatus(ctx) + if !storage.Writable { + return fmt.Errorf("cannot migrate: %s", storage.Detail) } - n, err := credentials.MigrateLegacyEnvFile(ctx) + n, err := hawkconfig.MigrateLegacyCredentials(ctx) if err != nil { return err } if n == 0 { cmd.Println("No legacy credential files found (already using secure storage).") } else { - cmd.Printf("Migrated %d key(s) to %s and removed legacy credential files.\n", n, credentials.PlatformSecretStoreName()) + cmd.Printf("Migrated %d key(s) to %s and removed legacy credential files.\n", n, hawkconfig.CredentialStoreName()) } return nil }, diff --git a/cmd/daemon.go b/cmd/daemon.go index 2f20b8f4..3eb88737 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -10,13 +10,14 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" - "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/daemon" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/multiagent/agents" "github.com/GrayCodeAI/hawk/internal/netutil" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/storage" @@ -81,6 +82,11 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { if err != nil { return nil, err } + var agentModel string + systemPrompt, agentModel, err = daemonAgentConfig(req.Agent, systemPrompt) + if err != nil { + return nil, err + } registry, err := defaultRegistry(settings) if err != nil { return nil, err @@ -88,6 +94,8 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { effectiveModel, effectiveProvider := effectiveModelAndProvider(settings) if req.Model != "" { effectiveModel = req.Model + } else if agentModel != "" { + effectiveModel = agentModel } sess := newHawkSession(settings, effectiveProvider, effectiveModel, systemPrompt, registry) sess.SetLogger(logger.New(io.Discard, logger.Error)) @@ -100,13 +108,9 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { daemon.SetVersion(version) srv := daemon.New(daemon.Config{Port: daemonPort, Host: daemonHost, APIKey: apiKey}, factory) - // Wire a lightweight provider-connectivity readiness probe. The default - // probe only checks "session factory wired"; this upgrades GET /v1/ready to - // also confirm the provider/catalog/credentials are usable via a cheap, - // short-timeout preflight (catalog presence + model selection). It is - // conservative: if preflight cannot positively confirm readiness it still - // reports ready as long as a session factory is wired and config loaded, so - // an uncertain/expensive check never makes a working daemon look broken. + // Wire Eyrie's authoritative local preflight into GET /v1/ready. A session + // factory only proves Hawk can attempt construction; readiness additionally + // requires Eyrie's provider state, catalog, credentials, and model selection. srv.SetReadyFn(daemonReadyProbe(factory)) addr, err := srv.Start() if err != nil { @@ -161,36 +165,66 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { } // daemonReadyProbe builds the readiness function installed via SetReadyFn. It -// performs a cheap provider-connectivity check (eyrie preflight: catalog + -// credentials + model selection) under a short timeout. The check is -// conservative by design: +// performs Eyrie's local preflight (provider state, catalog, credentials, and +// model selection) under a short timeout: // // - No session factory wired -> not ready ("engine not configured"). // - Preflight reports ready -> ready. -// - Preflight cannot confirm (no -// credentials yet, catalog cold, -// check times out, etc.) -> still ready, because a non-nil factory -// plus loadable config means the daemon can construct sessions; an -// uncertain probe must not flap a working daemon to 503. +// - Preflight reports incomplete -> not ready with the failed Eyrie check. // -// This never performs a paid/live model call — runtime.Preflight only inspects +// This never performs a paid/live model call — Eyrie preflight only inspects // local catalog/credential/model state — so it is safe to call on every probe. func daemonReadyProbe(factory daemon.SessionFactory) func() (bool, string) { + return daemonReadyProbeWithPreflight(factory, hawkconfig.EnginePreflightReport) +} + +func daemonReadyProbeWithPreflight(factory daemon.SessionFactory, preflight func(context.Context) hawkconfig.EnginePreflight) func() (bool, string) { return func() (bool, string) { if factory == nil { return false, "engine not configured" } + if preflight == nil { + return false, "Eyrie readiness probe not configured" + } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - report := runtime.Preflight(ctx) + report := preflight(ctx) if report.Ready { return true, "" } - // Conservative fallback: factory is wired, so sessions can be built. - // Report ready rather than letting an uncertain preflight (e.g. cold - // catalog) mark a usable daemon unavailable. - return true, "" + for _, check := range report.Checks { + if string(check.Status) == "fail" { + detail := strings.TrimSpace(check.Detail) + if detail == "" { + detail = "failed" + } + return false, fmt.Sprintf("Eyrie %s: %s", check.Name, detail) + } + } + if ctx.Err() != nil { + return false, "Eyrie preflight timed out" + } + return false, "Eyrie preflight is not ready" + } +} + +func daemonAgentConfig(name, basePrompt string) (string, string, error) { + name = strings.TrimSpace(name) + if name == "" { + return basePrompt, "", nil + } + agentDef, err := agents.Get(name) + if err != nil { + return "", "", &daemon.InvalidChatRequestError{ + Message: fmt.Sprintf("agent %q is not available", name), + Err: err, + } + } + prompt := strings.TrimSpace(agentDef.Prompt) + if prompt != "" { + basePrompt = prompt + "\n\n" + basePrompt } + return basePrompt, strings.TrimSpace(agentDef.Model), nil } func generateDaemonAPIKey() (string, error) { diff --git a/cmd/daemon_ready_test.go b/cmd/daemon_ready_test.go index fb1feddf..d67bc3ee 100644 --- a/cmd/daemon_ready_test.go +++ b/cmd/daemon_ready_test.go @@ -3,13 +3,19 @@ package cmd import ( "context" "encoding/json" + "errors" "net" "net/http" + "os" + "path/filepath" + "strings" "testing" "time" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/daemon" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/storage" ) // TestDaemonReadyProbe_NilFactory verifies the probe reports not-ready when no @@ -24,14 +30,30 @@ func TestDaemonReadyProbe_NilFactory(t *testing.T) { } } -// TestDaemonReadyProbe_FactoryWired verifies the conservative fallback: with a -// factory wired the probe reports ready even when preflight is uncertain (no -// credentials configured in the test environment). -func TestDaemonReadyProbe_FactoryWired(t *testing.T) { +// TestDaemonReadyProbe_FailedEyriePreflight verifies a factory alone does not +// make the daemon ready when Eyrie's authoritative preflight is incomplete. +func TestDaemonReadyProbe_FailedEyriePreflight(t *testing.T) { factory := func(daemon.ChatRequest) (*engine.Session, error) { return nil, nil } - ok, _ := daemonReadyProbe(factory)() - if !ok { - t.Fatalf("factory-wired daemon should report ready (conservative fallback)") + probe := daemonReadyProbeWithPreflight(factory, func(context.Context) hawkconfig.EnginePreflight { + return hawkconfig.EnginePreflight{Ready: false} + }) + ok, reason := probe() + if ok { + t.Fatal("failed Eyrie preflight must report not ready") + } + if reason == "" { + t.Fatal("failed Eyrie preflight must include a reason") + } +} + +func TestDaemonReadyProbe_ReadyEyriePreflight(t *testing.T) { + factory := func(daemon.ChatRequest) (*engine.Session, error) { return nil, nil } + probe := daemonReadyProbeWithPreflight(factory, func(context.Context) hawkconfig.EnginePreflight { + return hawkconfig.EnginePreflight{Ready: true} + }) + ok, reason := probe() + if !ok || reason != "" { + t.Fatalf("ready Eyrie preflight = (%v, %q), want (true, empty)", ok, reason) } } @@ -40,7 +62,9 @@ func TestDaemonReadyProbe_FactoryWired(t *testing.T) { func TestDaemonReadyProbe_AffectsReadyEndpoint(t *testing.T) { factory := func(daemon.ChatRequest) (*engine.Session, error) { return nil, nil } srv := daemon.New(daemon.Config{Port: 0, Host: "127.0.0.1"}, factory) - srv.SetReadyFn(daemonReadyProbe(factory)) + srv.SetReadyFn(daemonReadyProbeWithPreflight(factory, func(context.Context) hawkconfig.EnginePreflight { + return hawkconfig.EnginePreflight{Ready: false} + })) addr, err := srv.Start() if err != nil { @@ -68,14 +92,50 @@ func TestDaemonReadyProbe_AffectsReadyEndpoint(t *testing.T) { } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Errorf("expected 200 from /v1/ready with factory wired, got %d", resp.StatusCode) + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("expected 503 from /v1/ready when Eyrie is not ready, got %d", resp.StatusCode) } var body daemon.ReadyResponse if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode ready response: %v", err) } - if !body.Ready { - t.Errorf("expected Ready=true, got %+v", body) + if body.Ready || body.Reason == "" { + t.Errorf("expected Ready=false with reason, got %+v", body) + } +} + +func TestDaemonAgentConfig_AppliesNamedPersonaAndModel(t *testing.T) { + storage.SetTestDirs(t, t.TempDir()) + if err := os.MkdirAll(storage.PersonasDir(), 0o750); err != nil { + t.Fatal(err) + } + definition := `--- +name: reviewer +description: Reviews code +model: reviewer-model +--- +You are the reviewer persona.` + if err := os.WriteFile(filepath.Join(storage.PersonasDir(), "reviewer.md"), []byte(definition), 0o600); err != nil { + t.Fatal(err) + } + + prompt, model, err := daemonAgentConfig("reviewer", "base prompt") + if err != nil { + t.Fatalf("daemonAgentConfig: %v", err) + } + if model != "reviewer-model" { + t.Fatalf("model = %q, want reviewer-model", model) + } + if !strings.HasPrefix(prompt, "You are the reviewer persona.\n\n") || !strings.HasSuffix(prompt, "base prompt") { + t.Fatalf("persona prompt was not prepended: %q", prompt) + } +} + +func TestDaemonAgentConfig_UnknownPersonaIsInvalidRequest(t *testing.T) { + storage.SetTestDirs(t, t.TempDir()) + _, _, err := daemonAgentConfig("missing", "base prompt") + var requestErr *daemon.InvalidChatRequestError + if !errors.As(err, &requestErr) || requestErr.Message == "" { + t.Fatalf("error = %v, want InvalidChatRequestError with public message", err) } } diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go index c14ca881..51245821 100644 --- a/cmd/diagnostics.go +++ b/cmd/diagnostics.go @@ -11,10 +11,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/credentials" - eyrieruntime "github.com/GrayCodeAI/eyrie/runtime" - "github.com/GrayCodeAI/eyrie/setup" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/plugin" @@ -25,7 +21,12 @@ import ( ) func doctorReport(settings hawkconfig.Settings) string { - modelName, providerName := effectiveModelAndProvider(settings) + // Diagnostics must report the requested/effective selection even when its + // credential is missing; readiness and health sections explain why it is + // not yet usable. Hiding it as auto/default makes misconfiguration harder + // to diagnose. + selection := resolveSelection(settings) + modelName, providerName := selection.Model, selection.Provider if providerName == "" { providerName = "auto" } @@ -60,14 +61,15 @@ func doctorReport(settings hawkconfig.Settings) string { b.WriteString(fmt.Sprintf(" %s: not checked out\n", repo)) } } - b.WriteString("\n" + hawkconfig.FormatEcosystemPanel(context.Background(), provider, modelName) + "\n") + b.WriteString("\n" + hawkconfig.FormatEcosystemPanel(context.Background(), providerName, modelName) + "\n") b.WriteString("\n" + hawkconfig.FormatCatalogHealth(hawkconfig.CatalogHealthReport(context.Background())) + "\n") - b.WriteString("\n" + eyrieruntime.FormatPreflightReport(eyrieruntime.Preflight(context.Background())) + "\n") - b.WriteString("\n" + credentials.FormatStorageReport(credentials.StorageReportFor(context.Background())) + "\n") - if deployReport, err := hawkconfig.DeploymentStatusReport(context.Background(), modelName); err == nil { + preflight := hawkconfig.EnginePreflightReportWithSettings(context.Background(), settings, hawkconfig.EnginePreflightOptions{}) + b.WriteString("\n" + hawkconfig.FormatEnginePreflight(preflight) + "\n") + b.WriteString("\n" + hawkconfig.CredentialStorageStatus(context.Background()).Formatted + "\n") + if deployReport, err := hawkconfig.DeploymentStatusReportWithSettings(context.Background(), settings, modelName); err == nil { b.WriteString("\n" + deployReport + "\n") } - b.WriteString("\n" + envSummaryWithSelection(provider, modelName, false) + "\n") + b.WriteString("\n" + envSummaryWithSelection(providerName, modelName, false) + "\n") b.WriteString("\nGit:\n") if branch := branchSummary(); branch != "" { for _, line := range strings.Split(branch, "\n") { @@ -102,17 +104,14 @@ func doctorReport(settings hawkconfig.Settings) string { b.WriteString("\nInterrupted sessions: none\n") } - b.WriteString("\n" + healthCheckReport(settings, provider) + "\n") + b.WriteString("\n" + healthCheckReport(settings, providerName) + "\n") return strings.TrimRight(b.String(), "\n") } func healthCheckReport(settings hawkconfig.Settings, provider string) string { registry := health.NewRegistry() - ctx := context.Background() - apiKeyEnv := primaryAPIKeyEnvForProvider(ctx, provider) - apiKey := credentials.LookupSecret(ctx, apiKeyEnv) - registry.Register("api_key", health.APIKeyChecker(provider, apiKey)) + registry.Register("api_key", providerCredentialHealthChecker(provider)) // Settings validation registry.Register("config", func(ctx context.Context) health.Check { @@ -178,19 +177,43 @@ func healthCheckReport(settings hawkconfig.Settings, provider string) string { return strings.TrimRight(b.String(), "\n") } -func primaryAPIKeyEnvForProvider(ctx context.Context, provider string) string { - provider = strings.TrimSpace(provider) - if provider == "" || provider == "auto" { - provider = strings.TrimSpace(hawkconfig.ActiveProvider(ctx)) - } - if provider == "" { - return "" +func providerCredentialHealthChecker(provider string) health.Checker { + return func(ctx context.Context) health.Check { + start := time.Now() + providerID := diagnosticsProvider(ctx, provider) + name := "api_key" + label := "Provider" + if providerID != "" { + name = providerID + "_api_key" + label = providerID + } + + status := health.Unhealthy + message := label + " credential not configured" + if providerID != "" && hawkconfig.HasStoredCredentialForProvider(ctx, providerID) { + status = health.Healthy + message = label + " credential configured" + } + checkedAt := time.Now() + return health.Check{ + Name: name, + Status: status, + Message: message, + LastChecked: checkedAt, + Duration: checkedAt.Sub(start), + } } - compiled, err := setup.LoadCompiledCatalog(ctx) - if err != nil || compiled == nil { - return "" +} + +func diagnosticsProvider(ctx context.Context, provider string) string { + provider = strings.TrimSpace(provider) + if provider == "" || strings.EqualFold(provider, "auto") { + provider = strings.TrimSpace(hawkconfig.ActiveGateway(ctx)) + if provider == "" || strings.EqualFold(provider, "auto") { + provider = strings.TrimSpace(hawkconfig.EffectiveSelection(ctx, hawkconfig.SelectionOptions{}).Provider) + } } - return catalog.PrimaryAPIKeyEnvForProvider(compiled, provider) + return hawkconfig.ActiveProviderID(provider) } func settingsSummary(settings hawkconfig.Settings) string { diff --git a/cmd/diagnostics_test.go b/cmd/diagnostics_test.go index 558b94f2..258ad4bc 100644 --- a/cmd/diagnostics_test.go +++ b/cmd/diagnostics_test.go @@ -1,12 +1,28 @@ package cmd import ( + "context" "strings" "testing" + "time" + "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/resilience/health" ) +type diagnosticsContextKey struct{} + +type contextRecordingCredentialStore struct { + credentials.MapStore + contextValue any +} + +func (s *contextRecordingCredentialStore) Get(ctx context.Context, account string) (string, error) { + s.contextValue = ctx.Value(diagnosticsContextKey{}) + return s.MapStore.Get(ctx, account) +} + func TestDoctorReport(t *testing.T) { t.Parallel() settings := hawkconfig.Settings{} @@ -38,6 +54,92 @@ func TestDoctorReportProviderModelOrder(t *testing.T) { } } +func TestDoctorReportUsesResolvedProviderForChecks(t *testing.T) { + isolateCredentialHome(t) + hawkconfig.InvalidateConfigUICache() + credentials.SetDefaultStore(&credentials.MapStore{}) + t.Cleanup(func() { + credentials.SetDefaultStore(nil) + hawkconfig.InvalidateConfigUICache() + }) + + report := doctorReport(hawkconfig.Settings{ + Model: "claude-sonnet-4-20250514", + Provider: "anthropic", + }) + if !strings.Contains(report, "provider anthropic") || !strings.Contains(report, "anthropic_api_key") { + t.Fatalf("doctor checks did not use resolved provider:\n%s", report) + } +} + +func TestProviderCredentialHealthCheckerResolvesAuto(t *testing.T) { + isolateCredentialHome(t) + t.Setenv("EYRIE_CONFIG_DIR", t.TempDir()) + hawkconfig.InvalidateConfigUICache() + store := &contextRecordingCredentialStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { + credentials.SetDefaultStore(nil) + hawkconfig.InvalidateConfigUICache() + }) + + ctx := context.WithValue(context.Background(), diagnosticsContextKey{}, "checker-context") + if err := store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatal(err) + } + if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { + t.Fatal(err) + } + if err := hawkconfig.SetActiveModel(ctx, "gpt-4o"); err != nil { + t.Fatal(err) + } + store.contextValue = nil + + started := time.Now() + result := providerCredentialHealthChecker(" auto ")(ctx) + finished := time.Now() + if result.Name != "openrouter_api_key" { + t.Fatalf("name = %q, want openrouter_api_key", result.Name) + } + if result.Status != health.Healthy { + t.Fatalf("status = %q, want healthy (%s)", result.Status, result.Message) + } + if result.LastChecked.Before(started) || result.LastChecked.After(finished) { + t.Fatalf("last checked = %v, want between %v and %v", result.LastChecked, started, finished) + } + if result.Duration <= 0 { + t.Fatalf("duration = %v, want populated timing", result.Duration) + } + if store.contextValue != "checker-context" { + t.Fatalf("credential lookup context = %v, want checker-context", store.contextValue) + } +} + +func TestProviderCredentialHealthCheckerMissingIsUnhealthy(t *testing.T) { + isolateCredentialHome(t) + t.Setenv("EYRIE_CONFIG_DIR", t.TempDir()) + hawkconfig.InvalidateConfigUICache() + credentials.SetDefaultStore(&credentials.MapStore{}) + t.Cleanup(func() { + credentials.SetDefaultStore(nil) + hawkconfig.InvalidateConfigUICache() + }) + + result := providerCredentialHealthChecker("openai")(context.Background()) + if result.Name != "openai_api_key" { + t.Fatalf("name = %q, want openai_api_key", result.Name) + } + if result.Status != health.Unhealthy { + t.Fatalf("status = %q, want unhealthy (%s)", result.Status, result.Message) + } + if result.LastChecked.IsZero() { + t.Fatal("last checked timestamp was not populated") + } + if result.Duration < 0 { + t.Fatalf("duration = %v, want non-negative timing", result.Duration) + } +} + func TestSettingsSummary(t *testing.T) { t.Parallel() settings := hawkconfig.Settings{ diff --git a/cmd/errors.go b/cmd/errors.go index 498b3b3b..1d2f69b4 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -14,7 +14,6 @@ import ( "syscall" "time" - "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -56,7 +55,7 @@ func friendlyError(err error) string { for _, pk := range providerKeys { for _, pat := range pk.patterns { if strings.Contains(low, pat) { - return fmt.Sprintf("%s API key is missing or invalid. Run /config to save it in %s.", pk.provider, credentials.PlatformSecretStoreName()) + return fmt.Sprintf("%s API key is missing or invalid. Run /config to save it in %s.", pk.provider, hawkconfig.CredentialStoreName()) } } } diff --git a/cmd/errors_test.go b/cmd/errors_test.go index 5152652c..595ea282 100644 --- a/cmd/errors_test.go +++ b/cmd/errors_test.go @@ -141,7 +141,8 @@ func TestFriendlyErrorInvalidModel(t *testing.T) { if !strings.Contains(got, "/model") { t.Errorf("friendlyError(%q) = %q, should suggest /model", tt.errMsg, got) } - if !strings.Contains(got, "claude-sonnet") || !strings.Contains(got, "gpt-4o") { + example1, example2 := hawkconfig.ExampleModelHints() + if !strings.Contains(got, example1) || !strings.Contains(got, example2) { t.Errorf("friendlyError(%q) = %q, should suggest valid model names", tt.errMsg, got) } }) diff --git a/cmd/model_table.go b/cmd/model_table.go index 83b30e8d..e13d4d19 100644 --- a/cmd/model_table.go +++ b/cmd/model_table.go @@ -6,7 +6,7 @@ import ( "strings" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/eyrie/catalog" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/ui/icons" "github.com/mattn/go-runewidth" ) @@ -75,11 +75,17 @@ func computeModelTableLayout(viewWidth int, rows []modelTableRow) modelTableLayo } func modelTableRowFromOption(o configModelOption) modelTableRow { - name := catalog.DisplayModelLabel(o.ID, o.DisplayName) + name := strings.TrimSpace(o.DisplayName) if name == "" { name = shortModelID(o.ID) } - owner := catalog.DisplayModelOwner(o.Owner, o.ID) + owner := strings.TrimSpace(o.Owner) + if owner == "" { + owner = strings.TrimSpace(o.ProviderID) + } + if owner == "" { + owner = strings.TrimSpace(o.GatewayID) + } if owner == "" { owner = "—" } @@ -303,17 +309,26 @@ func modelTableFooter(total, scroll, end, allTotal int, muted lipgloss.Style) st return muted.Render(fmt.Sprintf("%s%s · enter to select", prefix, label)) } -func modelTableRowFromCatalogEntry(m catalog.ModelCatalogEntry) modelTableRow { +func modelTableRowFromCatalogEntry(m hawkconfig.EngineModel) modelTableRow { name := strings.TrimSpace(m.DisplayName) if name == "" { name = m.ID } - owner := catalog.ModelOwner(m) + owner := strings.TrimSpace(m.Owner) + if owner == "" { + owner = strings.TrimSpace(m.ProviderID) + } + if owner == "" { + owner = strings.TrimSpace(m.GatewayID) + } if owner == "" { owner = "—" } - free := m.InputPricePer1M <= 0 && m.OutputPricePer1M <= 0 - price := formatModelTablePriceCompact(m.InputPricePer1M, m.OutputPricePer1M) + free := m.PriceKnown && m.InputPricePer1M <= 0 && m.OutputPricePer1M <= 0 + price := "—" + if m.PriceKnown { + price = formatModelTablePriceCompact(m.InputPricePer1M, m.OutputPricePer1M) + } if free && price == "—" { price = "free" } diff --git a/cmd/model_table_test.go b/cmd/model_table_test.go index 4d4545fc..59002272 100644 --- a/cmd/model_table_test.go +++ b/cmd/model_table_test.go @@ -3,6 +3,8 @@ package cmd import ( "strings" "testing" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func TestFormatModelTablePrice(t *testing.T) { @@ -72,6 +74,17 @@ func TestModelTableRowFromOptionUnknownPrice(t *testing.T) { } } +func TestModelTableOwnerFallback(t *testing.T) { + option := modelTableRowFromOption(configModelOption{ProviderID: "deployment", GatewayID: "gateway"}) + if option.Provider != "deployment" { + t.Fatalf("option provider = %q, want deployment", option.Provider) + } + entry := modelTableRowFromCatalogEntry(hawkconfig.EngineModel{GatewayID: "gateway"}) + if entry.Provider != "gateway" { + t.Fatalf("entry provider = %q, want gateway", entry.Provider) + } +} + func TestTruncateRunes(t *testing.T) { if got := truncateRunes("anthropic/claude-opus-4.7-fast", 50); strings.Contains(got, "…") { t.Fatalf("unexpected truncation: %q", got) diff --git a/cmd/models.go b/cmd/models.go index b20e61cd..25d7b036 100644 --- a/cmd/models.go +++ b/cmd/models.go @@ -4,10 +4,9 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" - "github.com/GrayCodeAI/eyrie/catalog" - eyriecfg "github.com/GrayCodeAI/eyrie/config" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/spf13/cobra" ) @@ -33,9 +32,13 @@ var modelsRefreshCmd = &cobra.Command{ Aliases: []string{"update"}, Short: "Discover model catalog (eyrie remote + live provider APIs) into ~/.eyrie/model_catalog.json", RunE: func(cmd *cobra.Command, _ []string) error { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + settings, err := loadEffectiveSettings() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) defer cancel() - summary, err := hawkconfig.RefreshModelCatalogV1(ctx) + summary, err := hawkconfig.RefreshModelCatalogV1WithSettings(ctx, settings) if err != nil { return err } @@ -48,18 +51,18 @@ var modelsStatusCmd = &cobra.Command{ Use: "status", Short: "Show cached catalog metadata and deployment routing status", RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - cmd.Println(hawkconfig.FormatCatalogHealth(hawkconfig.CatalogHealthReport(ctx))) - cmd.Println() + ctx := cmd.Context() settings, err := loadEffectiveSettings() if err != nil { return err } + cmd.Println(hawkconfig.FormatCatalogHealth(hawkconfig.CatalogHealthReport(ctx))) + cmd.Println() modelName, _ := effectiveModelAndProvider(settings) if len(args) > 0 { modelName = args[0] } - report, err := hawkconfig.DeploymentStatusReport(ctx, modelName) + report, err := hawkconfig.DeploymentStatusReportWithSettings(ctx, settings, modelName) if err != nil { return err } @@ -73,8 +76,12 @@ var modelsRoutingPreviewCmd = &cobra.Command{ Short: "Print effective deployment routing JSON for a model", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + settings, err := loadEffectiveSettings() + if err != nil { + return err + } modelName := args[0] - out, err := hawkconfig.RoutingPreviewJSON(context.Background(), modelName) + out, err := hawkconfig.RoutingPreviewJSONWithSettings(cmd.Context(), settings, modelName) if err != nil { return err } @@ -87,49 +94,29 @@ var modelsListCmd = &cobra.Command{ Use: "list [provider]", Short: "List models from the eyrie catalog cache (or live provider API)", RunE: func(cmd *cobra.Command, args []string) error { + settings, err := loadEffectiveSettings() + if err != nil { + return err + } providerName := "" if len(args) > 0 { providerName = args[0] } - ctx := context.Background() - var models []catalog.ModelCatalogEntry - var err error + ctx := cmd.Context() + var models []hawkconfig.EngineModel if modelsListLive { if providerName == "" { return fmt.Errorf("provider required with --live (e.g. hawk models list canopywave --live --json)") } - models, err = catalog.FetchLiveModelEntriesForProvider(eyriecfg.DiscoveryEnvMap(ctx), hawkconfig.ActiveProviderID(providerName)) + models, err = hawkconfig.ListLiveEngineModelsWithSettings(ctx, settings, hawkconfig.ActiveProviderID(providerName)) } else { - models, err = hawkconfig.FetchModelsForProvider(providerName) + models, err = hawkconfig.FetchModelsForProviderWithSettings(ctx, settings, providerName) } if err != nil { return err } if modelsListJSON || modelsListRaw { - if modelsListRaw { - raw := make([]json.RawMessage, 0, len(models)) - for _, m := range models { - if len(m.LiveMetadata) > 0 { - raw = append(raw, m.LiveMetadata) - } - } - if len(raw) == 0 && modelsListLive { - for _, m := range models { - b, merr := json.Marshal(m) - if merr != nil { - return merr - } - raw = append(raw, b) - } - } - out, merr := json.MarshalIndent(raw, "", " ") - if merr != nil { - return merr - } - cmd.Println(string(out)) - return nil - } - out, merr := json.MarshalIndent(models, "", " ") + out, merr := marshalModelListJSON(models, modelsListRaw, modelsListLive) if merr != nil { return merr } @@ -150,10 +137,89 @@ var modelsListCmd = &cobra.Command{ }, } +// modelListJSONEntry is Hawk's versioned command-output contract. Keep this +// separate from Eyrie's host-facing Model DTO so engine-only fields can evolve +// without breaking users that consume `hawk models list --json`. +type modelListJSONEntry struct { + ID string `json:"id"` + InputPricePer1M float64 `json:"input_price_per_1m"` + OutputPricePer1M float64 `json:"output_price_per_1m"` + ContextWindow int `json:"context_window"` + MaxOutput int `json:"max_output"` + ServerTools []string `json:"server_tools,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Owner string `json:"owner,omitempty"` + LiveMetadata json.RawMessage `json:"live_metadata,omitempty"` +} + +func modelListJSONEntryFromEngine(m hawkconfig.EngineModel) modelListJSONEntry { + return modelListJSONEntry{ + ID: m.ID, + InputPricePer1M: m.InputPricePer1M, + OutputPricePer1M: m.OutputPricePer1M, + ContextWindow: m.ContextWindow, + MaxOutput: m.MaxOutputTokens, + ServerTools: append([]string(nil), m.Capabilities...), + DisplayName: m.DisplayName, + Description: m.Description, + Owner: m.Owner, + LiveMetadata: validModelLiveMetadata(m.LiveMetadata), + } +} + +func validModelLiveMetadata(raw json.RawMessage) json.RawMessage { + metadata := json.RawMessage(strings.TrimSpace(string(raw))) + if len(metadata) == 0 || !json.Valid(metadata) || string(metadata) == "null" { + return nil + } + return append(json.RawMessage(nil), metadata...) +} + +func marshalModelListJSON(models []hawkconfig.EngineModel, rawOnly, live bool) ([]byte, error) { + entries := make([]modelListJSONEntry, len(models)) + for i, model := range models { + entries[i] = modelListJSONEntryFromEngine(model) + } + return marshalModelListEntriesJSON(entries, rawOnly, live) +} + +func marshalModelListEntriesJSON(entries []modelListJSONEntry, rawOnly, live bool) ([]byte, error) { + if !rawOnly { + return json.MarshalIndent(entries, "", " ") + } + + raw := make([]json.RawMessage, 0, len(entries)) + for _, entry := range entries { + metadata := json.RawMessage(strings.TrimSpace(string(entry.LiveMetadata))) + if len(metadata) > 0 && json.Valid(metadata) && metadata[0] == '{' { + raw = append(raw, append(json.RawMessage(nil), metadata...)) + } + } + // Preserve the original --raw contract: cached output contains only native + // metadata, and mixed live output also omits rows without native metadata. + if len(raw) > 0 || !live { + return json.MarshalIndent(raw, "", " ") + } + + // Some provider APIs do not expose native model objects. Only in that live, + // all-metadata-absent case, return stable compatibility rows instead of an + // unhelpful empty result. + for _, entry := range entries { + entry.LiveMetadata = nil + fallback, err := json.Marshal(entry) + if err != nil { + return nil, err + } + raw = append(raw, fallback) + } + return json.MarshalIndent(raw, "", " ") +} + func init() { modelsListCmd.Flags().BoolVar(&modelsListJSON, "json", false, "Print full catalog entries as JSON (includes live_metadata when cached)") modelsListCmd.Flags().BoolVar(&modelsListLive, "live", false, "Fetch directly from provider API instead of cache") - modelsListCmd.Flags().BoolVar(&modelsListRaw, "raw", false, "With --json, print only provider live_metadata objects (same shape as /v1/models data[] items)") + modelsListCmd.Flags().BoolVar(&modelsListRaw, "raw", false, "Print provider-native model objects (live fetch falls back to stable rows when none are available)") modelsCmd.AddCommand(modelsRefreshCmd) modelsCmd.AddCommand(modelsListCmd) modelsCmd.AddCommand(modelsStatusCmd) diff --git a/cmd/models_test.go b/cmd/models_test.go new file mode 100644 index 00000000..3d896756 --- /dev/null +++ b/cmd/models_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "encoding/json" + "reflect" + "sort" + "strings" + "testing" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +func TestMarshalModelListJSONCompatibilityGolden(t *testing.T) { + entry := modelListJSONEntry{ + ID: "vendor/model-v2", + InputPricePer1M: 1.25, + OutputPricePer1M: 5, + ContextWindow: 128000, + MaxOutput: 8192, + ServerTools: []string{"web_search", "code_execution"}, + DisplayName: "Model V2", + Description: "A provider model", + Owner: "vendor", + LiveMetadata: json.RawMessage(`{"id":"provider-model-v2","owned_by":"vendor"}`), + } + + got, err := marshalModelListEntriesJSON([]modelListJSONEntry{entry}, false, false) + if err != nil { + t.Fatalf("marshalModelListEntriesJSON() error = %v", err) + } + want := `[ + { + "id": "vendor/model-v2", + "input_price_per_1m": 1.25, + "output_price_per_1m": 5, + "context_window": 128000, + "max_output": 8192, + "server_tools": [ + "web_search", + "code_execution" + ], + "display_name": "Model V2", + "description": "A provider model", + "owner": "vendor", + "live_metadata": { + "id": "provider-model-v2", + "owned_by": "vendor" + } + } +]` + if string(got) != want { + t.Fatalf("compatibility JSON changed\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + + var rows []map[string]json.RawMessage + if err := json.Unmarshal(got, &rows); err != nil { + t.Fatalf("decode compatibility JSON: %v", err) + } + wantKeys := []string{ + "context_window", "description", "display_name", "id", + "input_price_per_1m", "live_metadata", "max_output", "output_price_per_1m", + "owner", "server_tools", + } + gotKeys := make([]string, 0, len(rows[0])) + for key := range rows[0] { + gotKeys = append(gotKeys, key) + } + sort.Strings(gotKeys) + if !reflect.DeepEqual(gotKeys, wantKeys) { + t.Fatalf("JSON keys = %v, want %v", gotKeys, wantKeys) + } +} + +func TestModelListJSONEntryFromEnginePreservesLegacyFieldMapping(t *testing.T) { + model := hawkconfig.EngineModel{ + ID: "vendor/model", + DisplayName: "Model", + Description: "Description", + Owner: "vendor", + ProviderID: "deployment", + GatewayID: "gateway", + CanonicalID: "vendor/model", + ContextWindow: 200000, + MaxOutputTokens: 16000, + InputPricePer1M: 3, + OutputPricePer1M: 15, + PriceKnown: true, + Capabilities: []string{"web_search"}, + Source: "cache", + } + + entry := modelListJSONEntryFromEngine(model) + if entry.MaxOutput != model.MaxOutputTokens { + t.Fatalf("MaxOutput = %d, want %d", entry.MaxOutput, model.MaxOutputTokens) + } + if !reflect.DeepEqual(entry.ServerTools, model.Capabilities) { + t.Fatalf("ServerTools = %v, want %v", entry.ServerTools, model.Capabilities) + } + + out, err := marshalModelListJSON([]hawkconfig.EngineModel{model}, false, false) + if err != nil { + t.Fatalf("marshalModelListJSON() error = %v", err) + } + for _, forbidden := range []string{ + `"provider_id"`, `"gateway_id"`, `"canonical_id"`, `"max_output_tokens"`, + `"capabilities"`, `"price_known"`, `"source"`, + } { + if strings.Contains(string(out), forbidden) { + t.Fatalf("compatibility JSON leaked engine field %s: %s", forbidden, out) + } + } +} + +func TestMarshalModelListRawCachedUsesOnlyMetadataWhenPresent(t *testing.T) { + entries := []modelListJSONEntry{ + { + ID: "cached-with-live-metadata", + LiveMetadata: json.RawMessage(`{"id":"native-id","object":"model","owned_by":"vendor"}`), + }, + { + ID: "cached-without-live-metadata", + DisplayName: "Cached Model", + ContextWindow: 32000, + }, + } + + got, err := marshalModelListEntriesJSON(entries, true, false) + if err != nil { + t.Fatalf("marshalModelListEntriesJSON(raw) error = %v", err) + } + want := `[ + { + "id": "native-id", + "object": "model", + "owned_by": "vendor" + } +]` + if string(got) != want { + t.Fatalf("raw JSON changed\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestMarshalModelListRawLiveWithMetadataDoesNotMixFallbackRows(t *testing.T) { + entries := []modelListJSONEntry{ + {ID: "native", LiveMetadata: json.RawMessage(`{"id":"native"}`)}, + {ID: "fallback"}, + } + got, err := marshalModelListEntriesJSON(entries, true, true) + if err != nil { + t.Fatalf("marshalModelListEntriesJSON(raw live) error = %v", err) + } + if strings.Contains(string(got), `"fallback"`) || !strings.Contains(string(got), `"id": "native"`) { + t.Fatalf("live raw with native metadata must not mix output shapes, got %s", got) + } +} + +func TestMarshalModelListRawCachedWithoutMetadataIsEmpty(t *testing.T) { + got, err := marshalModelListEntriesJSON([]modelListJSONEntry{{ + ID: "safe-fallback", LiveMetadata: json.RawMessage(`not-json`), + }}, true, false) + if err != nil { + t.Fatalf("marshalModelListEntriesJSON(raw) error = %v", err) + } + if string(got) != "[]" { + t.Fatalf("cached raw without native metadata = %s, want []", got) + } +} + +func TestMarshalModelListRawLiveWithoutMetadataFallsBack(t *testing.T) { + got, err := marshalModelListEntriesJSON([]modelListJSONEntry{{ + ID: "safe-fallback", DisplayName: "Fallback", + }}, true, true) + if err != nil { + t.Fatalf("marshalModelListEntriesJSON(raw live) error = %v", err) + } + if strings.Contains(string(got), "null") || !strings.Contains(string(got), `"id": "safe-fallback"`) { + t.Fatalf("live raw without native metadata should use compatible rows, got %s", got) + } +} + +func TestValidModelLiveMetadataPreservesProviderObject(t *testing.T) { + want := json.RawMessage(`{"id":"native-id"}`) + got := validModelLiveMetadata(want) + if string(got) != string(want) { + t.Fatalf("modelLiveMetadata() = %s, want %s", got, want) + } +} diff --git a/cmd/options.go b/cmd/options.go index f768e550..b10e830d 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ctxrepomap "github.com/GrayCodeAI/hawk/internal/context/repomap" "github.com/GrayCodeAI/hawk/internal/engine" @@ -23,7 +22,6 @@ import ( hawkmodel "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/snapshot" "github.com/GrayCodeAI/hawk/internal/tool" - "github.com/GrayCodeAI/hawk/internal/types" ) func buildSystemPrompt() (string, error) { @@ -185,47 +183,36 @@ func injectRepoMap(base string) string { } func loadEffectiveSettings() (hawkconfig.Settings, error) { - settings, err := hawkconfig.LoadSettingsWithOverride(settingsFlag) - if err != nil { - return settings, err - } - // Register custom providers with eyrie only; models come from settings + catalog fetch. - for _, cp := range settings.CustomProviders { - if cp.Name == "" || cp.BaseURL == "" { - continue - } - _ = types.RegisterDynamicProvider(cp.Name, cp.BaseURL, cp.APIKeyEnv) - } - return settings, nil + return hawkconfig.LoadSettingsWithOverride(settingsFlag) } -func resolveSelection(settings hawkconfig.Settings) runtime.SelectionState { - return runtime.EffectiveSelection(context.Background(), runtime.SelectionOpts{ +func resolveSelection(settings hawkconfig.Settings) hawkconfig.Selection { + return hawkconfig.EffectiveSelectionWithSettings(context.Background(), settings, hawkconfig.SelectionOptions{ ProviderOverride: firstNonEmptyTrimmed(provider, settings.Provider), ModelOverride: firstNonEmptyTrimmed(model, settings.Model), }) } -func startupSelection(settings hawkconfig.Settings) runtime.SelectionState { +func startupSelection(settings hawkconfig.Settings) hawkconfig.Selection { providerOverride := firstNonEmptyTrimmed(provider, settings.Provider) modelOverride := firstNonEmptyTrimmed(model, settings.Model) explicitProvider, explicitModel := explicitSelection(context.Background()) - providerID := runtime.NormalizeProviderID(firstNonEmptyTrimmed(providerOverride, explicitProvider)) + providerID := hawkconfig.ActiveProviderID(firstNonEmptyTrimmed(providerOverride, explicitProvider)) modelID := strings.TrimSpace(firstNonEmptyTrimmed(modelOverride, explicitModel)) if providerID == "" && modelID != "" { - providerID = runtime.NormalizeProviderID(hawkconfig.ProviderOfModel(modelID)) + providerID = hawkconfig.ActiveProviderID(hawkconfig.ProviderOfModelWithSettings(settings, modelID)) } if modelID == "" && providerID != "" { - modelID = strings.TrimSpace(hawkconfig.DefaultModelForProvider(providerID)) + modelID = strings.TrimSpace(hawkconfig.DefaultModelForProviderWithSettings(settings, providerID)) } if providerID == "" { providerID = startupPlaceholderProvider } - return runtime.SelectionState{ + return hawkconfig.Selection{ Provider: providerID, Model: modelID, } @@ -239,11 +226,7 @@ func effectiveModelAndProvider(settings hawkconfig.Settings) (string, string) { return selection.Model, selection.Provider } -func newHawkSessionFromSelection(selection runtime.SelectionState, systemPrompt string, registry *tool.Registry) *engine.Session { - return engine.NewHawkSession(context.Background(), selection, selection.Provider, selection.Model, systemPrompt, registry) -} - -func newStartupHawkSession(selection runtime.SelectionState, systemPrompt string, registry *tool.Registry) *engine.Session { +func newStartupHawkSession(selection hawkconfig.Selection, systemPrompt string, registry *tool.Registry) *engine.Session { providerID := strings.TrimSpace(selection.Provider) if providerID == "" { providerID = startupPlaceholderProvider @@ -259,7 +242,7 @@ func newHawkSession(settings hawkconfig.Settings, effectiveProvider, effectiveMo if strings.TrimSpace(selection.Model) == "" { selection.Model = effectiveModel } - return newHawkSessionFromSelection(selection, systemPrompt, registry) + return engine.NewHawkSessionForSettings(context.Background(), settings, selection, selection.Provider, selection.Model, systemPrompt, registry) } func firstNonEmptyTrimmed(values ...string) string { @@ -382,13 +365,6 @@ func configureSessionHeavy(sess *engine.Session) { sess.MemorySvc().SetEnhanced(enhancedMem) enhancedMem.StartSession(fmt.Sprintf("session_%d", time.Now().UnixNano())) } - - // Direct-provider sessions still accept explicit API key injection. - if providerName := strings.TrimSpace(sess.Provider()); providerName != "" { - if key := hawkconfig.APIKeyForProvider(providerName); key != "" { - sess.SetAPIKey(providerName, key) - } - } } // bindChatSession wires TUI-only session metadata (persist id, compaction callbacks). diff --git a/cmd/options_welcome_test.go b/cmd/options_welcome_test.go index 760da4e5..889cadc2 100644 --- a/cmd/options_welcome_test.go +++ b/cmd/options_welcome_test.go @@ -18,6 +18,7 @@ func isolateCredentialHome(t *testing.T) { _ = os.MkdirAll(hawkDir, 0o700) t.Setenv("HOME", home) t.Setenv("HAWK_CONFIG_DIR", hawkDir) + t.Setenv("EYRIE_CONFIG_DIR", filepath.Join(home, "eyrie")) } func TestEffectiveModelAndProvider_ClearsWithoutCredentials(t *testing.T) { @@ -34,7 +35,7 @@ func TestEffectiveModelAndProvider_ClearsWithoutCredentials(t *testing.T) { if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { t.Fatal(err) } - if err := hawkconfig.SetActiveModel(ctx, "moonshotai/kimi-k2.6"); err != nil { + if err := hawkconfig.SetActiveModel(ctx, "gpt-4o"); err != nil { t.Fatal(err) } @@ -60,7 +61,7 @@ func TestEffectiveModelAndProvider_KeepsWithCredentials(t *testing.T) { if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { t.Fatal(err) } - if err := hawkconfig.SetActiveModel(ctx, "openrouter/auto"); err != nil { + if err := hawkconfig.SetActiveModel(ctx, "gpt-4o"); err != nil { t.Fatal(err) } diff --git a/cmd/review_analyze.go b/cmd/review_analyze.go index fa78e89a..ee2837bb 100644 --- a/cmd/review_analyze.go +++ b/cmd/review_analyze.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/runtime" reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" hawkSight "github.com/GrayCodeAI/hawk/internal/bridge/sight" - "github.com/GrayCodeAI/hawk/internal/types" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/ui/icons" sightLib "github.com/GrayCodeAI/sight" "github.com/spf13/cobra" @@ -124,18 +124,15 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { return nil } - // Build sight bridge from the runtime-owned transport resolution. - transport, err := runtime.ResolveChatTransport(context.Background(), runtime.ChatTransportOpts{ - Selection: runtime.SelectionOpts{ - ProviderOverride: strings.TrimSpace(provider), - ModelOverride: analyzeModel, - }, + // Build the Sight bridge through Hawk's Eyrie engine boundary. + ctx := context.Background() + selection := hawkconfig.EffectiveSelection(ctx, hawkconfig.SelectionOptions{ + ProviderOverride: strings.TrimSpace(provider), + ModelOverride: strings.TrimSpace(analyzeModel), }) + chatProvider, providerID, err := engine.BuildChatProvider(ctx, selection, strings.TrimSpace(provider)) if err != nil { - return fmt.Errorf("resolve runtime transport: %w", err) - } - if transport.Provider == nil { - return fmt.Errorf("runtime transport unavailable for provider %q", transport.Selection.Provider) + return fmt.Errorf("resolve engine transport: %w", err) } var opts []sightLib.Option @@ -144,12 +141,11 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { } opts = append(opts, sightLib.WithConcerns(analysisType)) - bridge := hawkSight.NewBridge(types.WrapClientProvider(transport.Provider), transport.Selection.Provider, opts...) + bridge := hawkSight.NewBridge(chatProvider, providerID, opts...) if !bridge.Ready() { return fmt.Errorf("sight bridge not ready (check API key)") } - ctx := context.Background() if analyzeTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, analyzeTimeout) diff --git a/cmd/review_run.go b/cmd/review_run.go index d68af2ba..528e8ddc 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/runtime" reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" hawkSight "github.com/GrayCodeAI/hawk/internal/bridge/sight" - "github.com/GrayCodeAI/hawk/internal/types" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/ui/icons" sightLib "github.com/GrayCodeAI/sight" "github.com/spf13/cobra" @@ -86,20 +86,16 @@ func runReviewRun(_ *cobra.Command, args []string) error { return nil } - // Build sight bridge from the runtime-owned transport resolution. - transport, err := runtime.ResolveChatTransport(context.Background(), runtime.ChatTransportOpts{ - Selection: runtime.SelectionOpts{ - ProviderOverride: strings.TrimSpace(provider), - ModelOverride: reviewRunModel, - }, + // Build the Sight bridge through Hawk's Eyrie engine boundary. + ctx := context.Background() + selection := hawkconfig.EffectiveSelection(ctx, hawkconfig.SelectionOptions{ + ProviderOverride: strings.TrimSpace(provider), + ModelOverride: strings.TrimSpace(reviewRunModel), }) + chatProvider, providerID, err := engine.BuildChatProvider(ctx, selection, strings.TrimSpace(provider)) if err != nil { _ = store.SetStatus(id, ReviewStatusFailed) - return silentErr(fmt.Errorf("resolve runtime transport: %w", err), "init bridge") - } - if transport.Provider == nil { - _ = store.SetStatus(id, ReviewStatusFailed) - return silentErr(fmt.Errorf("runtime transport unavailable for provider %q", transport.Selection.Provider), "init bridge") + return silentErr(fmt.Errorf("resolve engine transport: %w", err), "init bridge") } var opts []sightLib.Option @@ -114,13 +110,12 @@ func runReviewRun(_ *cobra.Command, args []string) error { opts = append(opts, sightLib.WithConcerns(concerns...)) } - bridge := hawkSight.NewBridge(types.WrapClientProvider(transport.Provider), transport.Selection.Provider, opts...) + bridge := hawkSight.NewBridge(chatProvider, providerID, opts...) if !bridge.Ready() { _ = store.SetStatus(id, ReviewStatusFailed) return silentErr(fmt.Errorf("sight bridge not ready"), "init bridge") } - ctx := context.Background() if reviewRunTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, reviewRunTimeout) diff --git a/cmd/root.go b/cmd/root.go index 451d6cca..8b1b1e08 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/onboarding" @@ -64,6 +63,7 @@ var ( noContainer bool recoverFlag bool startupProfileFlag bool + preflightLiveFlag bool ) var ( @@ -111,7 +111,6 @@ var rootCmd = &cobra.Command{ } } // Defer credential migration until chat/print (keeps cold paths fast). - hawkconfig.PrepareCredentialDiscovery(context.Background()) logMigrateProviderSecretsError(logger.Default(), hawkconfig.MigrateProviderSecrets()) if settings, err := loadEffectiveSettings(); err == nil { @@ -227,6 +226,7 @@ func init() { rootCmd.Flags().BoolVar(&skipCatalogRefreshFlag, "no-auto-catalog-refresh", false, "disable automatic catalog refresh when cache is missing, empty, or stale") rootCmd.Flags().BoolVar(&recoverFlag, "recover", false, "scan for interrupted sessions and offer to resume") rootCmd.Flags().BoolVar(&startupProfileFlag, "startup-profile", false, "print startup performance profile") + preflightCmd.Flags().BoolVar(&preflightLiveFlag, "live", false, "verify selected provider connectivity and authentication") rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(setupCmd) rootCmd.AddCommand(initCmd) @@ -384,11 +384,31 @@ var doctorCmd = &cobra.Command{ var preflightCmd = &cobra.Command{ Use: "preflight", - Short: "Check hawk is ready to chat (catalog, credentials, model)", + Short: "Check local readiness; use --live to verify the selected provider", RunE: func(cmd *cobra.Command, args []string) error { - r := runtime.Preflight(context.Background()) - cmd.Println(runtime.FormatPreflightReport(r)) + settings, err := loadEffectiveSettings() + if err != nil { + return err + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + if preflightLiveFlag { + limit := timeout + if limit <= 0 { + limit = 15 * time.Second + } + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, limit) + defer cancel() + } + r := hawkconfig.EnginePreflightReportWithSettings(ctx, settings, hawkconfig.EnginePreflightOptions{VerifyLive: preflightLiveFlag}) + cmd.Println(hawkconfig.FormatEnginePreflight(r)) if !r.Ready { + if preflightLiveFlag { + return fmt.Errorf("live preflight failed — check the selected provider credential and network access") + } return fmt.Errorf("preflight failed — run hawk and complete /config") } return nil @@ -449,7 +469,11 @@ var configCmd = &cobra.Command{ if len(args) < 2 { return fmt.Errorf("usage: hawk config routing-preview ") } - out, err := hawkconfig.RoutingPreviewJSON(context.Background(), strings.Join(args[1:], " ")) + settings, err := loadEffectiveSettings() + if err != nil { + return err + } + out, err := hawkconfig.RoutingPreviewJSONWithSettings(cmd.Context(), settings, strings.Join(args[1:], " ")) if err != nil { return err } diff --git a/cmd/session_sync.go b/cmd/session_sync.go index 063b9e76..2bbd4032 100644 --- a/cmd/session_sync.go +++ b/cmd/session_sync.go @@ -78,7 +78,7 @@ func (m *chatModel) bootstrapSessionForChat() error { m.session.SetProvider(selection.Provider) m.session.SetModel(selection.Model) - if err := engine.RebuildSessionTransport(context.Background(), m.session, selection, selection.Provider); err != nil { + if err := engine.RebuildSessionTransportForSettings(context.Background(), m.settings, m.session, selection, selection.Provider); err != nil { return err } configureSessionHeavy(m.session) diff --git a/cmd/session_sync_test.go b/cmd/session_sync_test.go index 336984a0..262f2a09 100644 --- a/cmd/session_sync_test.go +++ b/cmd/session_sync_test.go @@ -24,13 +24,13 @@ func TestSyncSessionFromPersistedSelection_FillsEmptySessionModel(t *testing.T) _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") - _ = hawkconfig.SetActiveModel(ctx, "moonshotai/kimi-k2.6") + _ = hawkconfig.SetActiveModel(ctx, "gpt-4o") sess := engine.NewSession("", "", "test", nil) syncSessionFromPersistedSelection(sess) - if got := sess.Model(); got != "moonshotai/kimi-k2.6" { - t.Fatalf("model = %q, want moonshotai/kimi-k2.6", got) + if got := sess.Model(); got != "openai/gpt-4o" { + t.Fatalf("model = %q, want openai/gpt-4o", got) } if got := sess.Provider(); got != "openrouter" { t.Fatalf("provider = %q, want openrouter", got) @@ -51,7 +51,7 @@ func TestEnsureSessionReadyForChat_UsesPersistedModel(t *testing.T) { _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") - _ = hawkconfig.SetActiveModel(ctx, "moonshotai/kimi-k2.6") + _ = hawkconfig.SetActiveModel(ctx, "gpt-4o") m := &chatModel{session: engine.NewSession("", "", "test", nil)} if err := m.ensureSessionReadyForChat(); err != nil { @@ -96,7 +96,7 @@ func TestEnsureSessionReadyForChat_AppliesDeferredSystemContextOnce(t *testing.T _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") - _ = hawkconfig.SetActiveModel(ctx, "moonshotai/kimi-k2.6") + _ = hawkconfig.SetActiveModel(ctx, "gpt-4o") m := &chatModel{ session: engine.NewSession("", "", "base", nil), diff --git a/docs/DEVELOPER-PATH.md b/docs/DEVELOPER-PATH.md index cc01af57..a3eddbfa 100644 --- a/docs/DEVELOPER-PATH.md +++ b/docs/DEVELOPER-PATH.md @@ -9,7 +9,7 @@ For Hawk, the developer path is the minimum local setup required to chat, edit c - A provider credential stored in the OS secret store - A model selected in Hawk settings - A local model catalog available through eyrie -- No plaintext API keys left in `provider.json` or legacy env files +- No plaintext API keys left in Eyrie's configured `provider.json` or legacy env files - Safe defaults for Bash execution and filesystem access - Optional but healthy ecosystem integrations like yaad memory @@ -49,6 +49,10 @@ hawk credentials status hawk preflight ``` +`hawk preflight` is a local-ready check; it does not contact a provider. To +live-verify the selected provider credential and connectivity, use `/config` +validation or `hawk models list --live`. + ### 3. Select a model Pick a model in `/config`. Hawk stores the selected model in settings and uses eyrie for provider routing and catalog resolution. @@ -63,10 +67,14 @@ hawk models refresh `hawk path` treats these as important security conditions: -- `provider.json` must not contain secret fields +- Eyrie's resolved `provider.json` must not contain secret fields - legacy `~/.hawk/env` or `~/.hawk/.env` files should be migrated away - sensitive files like provider config and SSH paths should be blocked from agent reads +Eyrie resolves provider state from `EYRIE_CONFIG_DIR` first, then +`HAWK_CONFIG_DIR` for compatibility, then the platform user-config directory. +Hawk protects that resolved path even when it is customized or symlinked. + If Hawk detects old plaintext secrets, run Hawk once and complete `/config`, or remove the secret fields manually after backing up the file. Read the full credential and isolation model in [SECURITY-DEVELOPER.md](./SECURITY-DEVELOPER.md). @@ -85,7 +93,7 @@ hawk path --strict `hawk path` also verifies the core support layer behind Hawk: -- `eyrie` for provider routing and preflight readiness +- `eyrie` for provider routing and local preflight readiness - `tok` for token estimation and compression - `yaad` for optional persistent memory diff --git a/docs/DYNAMIC-MODELS.md b/docs/DYNAMIC-MODELS.md index 0b2b5582..ffa7b223 100644 --- a/docs/DYNAMIC-MODELS.md +++ b/docs/DYNAMIC-MODELS.md @@ -1,119 +1,119 @@ # Dynamic Model Discovery — Hawk Developer Guide -## Overview - -Hawk uses **eyrie** for all model discovery, provider routing, and credential management. Hawk never calls an LLM API directly or hardcodes provider logic. - -The model pipeline has three layers: - +## Ownership and flow + +Hawk is the product face; Eyrie is the provider engine. Hawk owns the CLI/TUI, +model-picker presentation, user intent, and output compatibility. The +`eyrie/engine` facade alone owns provider registry details, credentials, +discovery, catalog/cache policy, model aliases, deployment routing, and chat +transport. + +```text +provider APIs / remote catalog / local cache + | + v + eyrie/engine.Engine + catalog + credentials + routing + | + stable host DTOs and methods + v + Hawk composition and presentation + /config | models | conversation ``` -Remote catalog (langdag.com/.../catalog.json) - ↓ fetch -Local cache (~/.eyrie/model_catalog.json) - ↓ merge with live API data -Compiled catalog (in-memory CompiledCatalogV1) - ↓ query -Model picker (eyrieclient.ListModels) -``` - -## Provider registry -Single source of truth: `eyrie/catalog/registry/providers.go` +Production Hawk packages must not import Eyrie packages below +`github.com/GrayCodeAI/eyrie/engine`. Shell and AST guards enforce a +zero-exception boundary. -12 setup gateways registered (see `providerSpecs()` in eyrie): +## Hawk composition boundary -| Provider | ID | Credential | -|----------|----|------------| -| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | -| OpenAI | `openai` | `OPENAI_API_KEY` | -| Google Gemini | `gemini` | `GEMINI_API_KEY` | -| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | -| xAI (Grok) | `grok` | `XAI_API_KEY` | -| Z.AI | `z-ai` | `ZAI_API_KEY` | -| CanopyWave | `canopywave` | `CANOPYWAVE_API_KEY` | -| OpenCode Go | `opencodego` | `OPENCODEGO_API_KEY` | -| Kimi (Moonshot) | `kimi` | `MOONSHOT_API_KEY` | -| Xiaomi (MiMo) Pay-as-you-go | `xiaomi_mimo_payg` | `XIAOMI_MIMO_PAYG_API_KEY` | -| Xiaomi (MiMo) Token Plan | `xiaomi_mimo_token_plan` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY` | -| Ollama (local) | `ollama` | `OLLAMA_BASE_URL` | +`internal/config` is Hawk's control-plane composition root. It creates an +Eyrie engine and projects engine models into Hawk UI and command contracts: -### Xiaomi MiMo +```go +models, err := config.ListEngineModels(ctx, "anthropic", false) +live, err := config.ListEngineModels(ctx, "anthropic", true) +public, err := config.ListPublicEngineModels(ctx, "xiaomi_mimo_payg") +``` -Two `/config` gateway rows (not one). Each product uses a **single key** for both OpenAI-compat (`/v1/chat/completions`) and Anthropic-compat (`/v1/messages`) on the matching host. Token Plan requires region `cn`, `sgp`, or `ams` before paste. Legacy `XIAOMI_MIMO_API_KEY` maps to pay-as-you-go. Details: `eyrie/docs/guides/CREDENTIAL-SETUP-FLOW.md` (MiMo section). +Conversation construction goes through Hawk's `internal/engine` adapter. The +adapter translates Hawk-owned message, tool, usage, and stream DTOs to the +Eyrie engine facade; conversation history, WAL, resume, approvals, and tool +execution remain Hawk-owned. -### Model discovery (all gateways) +## Catalog and live discovery -Every setup gateway lists models from the provider’s live API (or Ollama tags) only. Without credentials (or Ollama URL), zero models. After paste/save, `/config` probes and `ListModels` hits the live fetcher only. There is no remote-catalog bootstrap for picker models. +The Eyrie engine combines its provider registry, provider-scoped live +discovery, and the cache at `~/.eyrie/model_catalog.json` by default. Override +the cache with `EYRIE_MODEL_CATALOG_PATH`. Credential and state dependencies +are injected into each Engine instance, so discovery does not need hidden +process-global credentials. -## Hawk API (via `internal/eyrieclient`) +Use the normal cache-backed path for repeatable UI and automation: -Hawk production code must use `internal/eyrieclient/` — never import eyrie packages directly. +```bash +hawk models list anthropic +hawk models list anthropic --json +``` -### Catalog functions +Use a provider-scoped live request when current connectivity and credentials +must be checked: -```go -// Load the compiled catalog from cache -compiled, err := eyrieclient.LoadCompiledCatalogV1(ctx, opts) - -// Query models -models := eyrieclient.ModelEntriesForProvider(compiled, "anthropic") -modelID := eyrieclient.FirstModelForProvider(compiled, "anthropic") -gateway := eyrieclient.GatewayForModel(compiled, "claude-sonnet-4-6") -providers := eyrieclient.ProviderIDsFromCompiled(compiled) -liveOnly := eyrieclient.IsLiveOnlyProvider("ollama") - -// Display -label := eyrieclient.DisplayModelLabel(id, displayName) -owner := eyrieclient.DisplayModelOwner(owner, id) +```bash +hawk models list anthropic --live +hawk models list anthropic --live --json +hawk models list anthropic --live --raw ``` -### Credential functions +`hawk preflight` reports **local readiness**: usable local state, a selected +model, and presence of the required stored credential. It is intentionally +cheap and does not prove that a remote provider accepts that credential. +Treat a successful provider-scoped `--live` request (or `/config` live +validation) as **live verified**. -```go -name := eyrieclient.PlatformSecretStoreName() -hasKey := eyrieclient.HasSecret(ctx, "OPENAI_API_KEY") -secret := eyrieclient.LookupSecret(ctx, "OPENAI_API_KEY") -report := eyrieclient.StorageReportFor(ctx) -``` +## Stable command output -### Model listing +`hawk models list --json` is a Hawk-owned compatibility contract, not a direct +serialization of Eyrie's evolving `engine.Model` DTO. Its stable fields are: -```go -// Unified model listing — live API for setup providers -models, err := eyrieclient.ListModels(ctx, eyrieclient.ListModelsOpts{ - ProviderID: "anthropic", - Source: eyrieclient.ListSourceAuto, // live for registry providers -}) - -// Shortcut for single provider -models, err := eyrieclient.ListModelsForProvider(ctx, "anthropic") +```text +id, input_price_per_1m, output_price_per_1m, context_window, max_output, +server_tools, display_name, description, owner, live_metadata ``` -### Session construction +New fields must be additive. `--raw` returns provider-native +`live_metadata` objects when available; for cache/public rows without native +metadata, it returns the stable Hawk compatibility row instead of `null`. -```go -// Build a chat client with deployment routing support -sess := eyrieclient.NewHawkSession(ctx, useDeploymentRouting, provider, model, systemPrompt, registry) -``` +## Custom gateways -## Adding a new provider +Hawk converts effective `custom_providers` settings into +`engine.Options.CustomGateways` at its composition root. Custom gateway +metadata is snapshotted per Engine instance. Do not register custom gateways +in Eyrie process-global state: tests, parallel sessions, and future multi-tenant +hosts must be isolated from one another. -1. Add one `ProviderSpec` row in `eyrie/catalog/registry/providers.go` -2. Add `CredentialsEnvFallbacks` if the API key has known alternative env var names -3. If live list API exists: implement fetcher in `catalog/live/fetchers.go` and register in `Registry` map -4. Add live fetcher key to the provider spec (`LiveFetcherKey`) -5. Add probe base URL for credential validation -7. No hawk code changes needed +## Adding or changing a provider -## Testing model discovery +1. Implement registry, discovery, credentials, aliases, and transport behavior + behind Eyrie's engine facade. +2. Add Eyrie tests for cache and live discovery, credential status, selection, + and generation/streaming. +3. Commit and verify standalone Eyrie. +4. Advance Hawk's `external/eyrie` submodule to that exact commit, then update + Hawk's module version when the Eyrie revision is published. +5. Verify both the clean submodule (`go.work`) and published-module + (`GOWORK=off`) build modes. -```bash -# Refresh catalog and list models for a provider -EYRIE_MODEL_CATALOG_REFRESH=1 hawk config model list --provider anthropic +Hawk changes are needed only for a new product behavior or an additive +Hawk-owned presentation field—not for provider-specific mechanics. -# Preflight diagnostics -hawk preflight +## Checks -# Doctor check -hawk doctor +```bash +hawk models refresh +hawk models status +hawk preflight +make eyrie-engine-guard +go test ./cmd ./internal/config ./internal/engine -count=1 ``` diff --git a/docs/OTEL-CONVENTIONS.md b/docs/OTEL-CONVENTIONS.md index 82dd8a3e..5824d522 100644 --- a/docs/OTEL-CONVENTIONS.md +++ b/docs/OTEL-CONVENTIONS.md @@ -17,17 +17,15 @@ concerns that the GenAI spec does not yet standardize. - Upstream spec: -## Reference implementation +## Reference ownership -**eyrie already ships the reference OTel implementation.** Other repos should -mirror it rather than invent their own tracing layer. +Eyrie owns provider-call instrumentation behind its `eyrie/engine` facade. +Its lower provider layer contains the reference OTel decorator for chat and +stream calls: it starts a client span, records provider/model/usage attributes, +sets status from the result, and ends a streamed span on completion. That +decorator is an Eyrie implementation detail; Hawk must not import or compose it +directly. -- `eyrie/client/tracing.go` defines `TracingProvider`, a `Provider` decorator - that wraps any LLM provider with real OpenTelemetry spans - (`go.opentelemetry.io/otel`) for `Chat` and `StreamChat`. It starts a client - span, records provider/model/usage attributes, sets span status from the - result, and ends the span when a streamed response completes. -- `eyrie/cmd/eyrie/main.go` wires it up via `client.NewTracingProvider(...)`. - `eyrie/internal/observability/observability.go` provides a stdlib-only, zero-dependency telemetry/metrics layer (spans, latency histograms, Prometheus + JSON export) for environments that cannot pull in the OTel SDK. @@ -36,9 +34,10 @@ mirror it rather than invent their own tracing layer. of hard-coding strings. A pinning test (`genai_semconv_test.go`) guards the exact key values. -When adding tracing to hawk, yaad, tok, or trace, prefer wrapping the -provider/operation the same way `TracingProvider` does, and use the attribute -keys in this document verbatim. +When adding tracing to Hawk, propagate trace context through the Engine call and +use the attribute keys in this document. Eyrie wraps provider operations; +Hawk wraps product turns and tools. Yaad, Tok, and Trace instrument only their +own operations. ## Span kinds and names @@ -112,8 +111,8 @@ Mapping: ## Per-repo guidance -- **eyrie** — reference impl. Already emits provider/model/usage via - `TracingProvider`; align attribute keys to `gen_ai.*` over time. +- **eyrie** — owns provider/model/usage spans behind `eyrie/engine`; align + attribute keys to `gen_ai.*` over time. - **hawk** — daemon/orchestrator. Already has OTel hooks (`HAWK_CODE_ENABLE_TELEMETRY`, `HAWK_CODE_OTEL_SHUTDOWN_TIMEOUT_MS`). Emit `agent.id` and `session.id` on agent-turn spans; propagate them downstream to diff --git a/docs/SECURITY-DEVELOPER.md b/docs/SECURITY-DEVELOPER.md index 9e447b98..50c4fba3 100644 --- a/docs/SECURITY-DEVELOPER.md +++ b/docs/SECURITY-DEVELOPER.md @@ -6,7 +6,7 @@ This document describes how hawk and eyrie handle API keys and agent isolation f - API keys live only in the OS secret store (macOS Keychain / Linux GNOME Keyring or KWallet). - Hawk does not read API keys from `.env`, shell env, or plaintext files. -- `~/.hawk/provider.json` holds routing and deployment metadata only — never secrets on disk. +- Eyrie's `provider.json` holds routing and deployment metadata only — never secrets on disk. - Hawk talks to eyrie without putting keys in JSON or chat messages. - Agents run Bash inside Docker when possible; file tools cannot read credential paths. @@ -14,9 +14,14 @@ This document describes how hawk and eyrie handle API keys and agent isolation f | Write | Read | Remove | |-------|------|--------| -| `/config` paste flow → eyrie `runtime.SetCredential` | `credentials.LookupSecret` (keychain only) | `/config key remove` or `hawk credentials remove` | +| `/config` paste flow → `eyrie/engine.Engine.SaveCredential` | `Engine.ResolveCredential` (secret store only) | `/config key remove` or `hawk credentials remove` | -On startup, hawk calls `PrepareCredentialDiscovery()` to one-time migrate legacy `~/.hawk/env` / `~/.hawk/.env` into the keychain and delete those files. +On startup, Hawk asks the Eyrie engine facade to migrate legacy +`~/.hawk/env` / `~/.hawk/.env` values into the secret store and delete those +files. It also imports recognized historical secret fields from +`provider.json` before atomically rewriting that file with metadata only. A +secret-store or state-write failure aborts the rewrite and rolls back newly +imported values. Check status: `hawk credentials status`, `hawk path`, or `hawk preflight`. @@ -26,10 +31,10 @@ Check status: `hawk credentials status`, `hawk path`, or `hawk preflight`. User pastes API key in /config | v -hawk PersistAPIKey -> eyrie runtime.SetCredential (OS secret store) +Hawk /config -> Eyrie engine credential service (OS secret store) | v -eyrie Apply / discover (credentials from store, not JSON body) +Eyrie engine discover/apply (credentials from store, not JSON body) | v SetupUI JSON (display_name + canonical_id per model) @@ -40,10 +45,14 @@ User picks model -> settings.json (canonical id only) Remove a stored key: `/config key remove` (interactive picker). -## Hawk to eyrie +## Hawk to Eyrie -- **Apply**: credentials passed from the OS store; no `api_key` fields in request payloads. -- **Chat**: `model_id` + messages only; eyrie resolves provider and reads secrets internally. +- **Control plane**: Hawk calls only `eyrie/engine`; no lower Eyrie package is a + production import. +- **Discovery/apply**: credentials are resolved from the Engine's injected + secret store; provider state and request bodies remain sanitized. +- **Chat**: Hawk sends model intent, messages, and tool definitions; Eyrie + resolves the gateway and reads secrets internally. ## Agent isolation @@ -62,15 +71,33 @@ When the container is ready, `session.ContainerExecutor` runs Bash in the contai ### Blocked for agents (host or container policy) -- **Read** tool: `~/.hawk/env`, `~/.hawk/.env`, `~/.hawk/provider.json`, `~/.ssh/*`, etc. +- **Read** tool: legacy Hawk env files, Eyrie's configured `provider.json`, + `~/.ssh/*`, etc. - **Bash**: `printenv`, `env`, reading hawk env paths, echoing `*_API_KEY` variables. Use `--no-container` only for debugging; secure mode warns because host Bash can access more of the filesystem. ## Migration -- **Legacy env files**: `MigrateLegacyEnvFile()` on startup imports `~/.hawk/env` / `~/.hawk/.env` → keychain → deletes files. -- **provider.json secrets**: `MigrateProviderSecrets()` strips secret fields (backup: `provider.json.pre-secret-migrate.bak`). +- **Legacy env files**: startup migration imports `~/.hawk/env` and + `~/.hawk/.env` into the OS secret store, then deletes the plaintext files. +- **provider.json secrets**: Eyrie transactionally imports recognized top-level + and deployment credentials, atomically writes sanitized metadata, and uses a + temporary `provider.json.pre-secret-migrate.bak` only during the transaction. +- **All subsequent writes**: the Eyrie engine applies the same sanitization and + atomic-write path, so migrated secret fields cannot be reintroduced. + +## Provider state path + +Eyrie owns the provider-state path. Resolution order is: + +1. `EYRIE_CONFIG_DIR/provider.json` +2. `HAWK_CONFIG_DIR/provider.json` (compatibility fallback) +3. the platform user-config directory under `hawk/provider.json` + +Hawk's Read/Edit/Write and Bash safety checks protect the resolved path, +including a custom or symlinked `EYRIE_CONFIG_DIR`; protection is not limited +to the historical default provider-state location. ## Environment variables @@ -79,10 +106,12 @@ Non-secret overrides only (hawk does not load provider API keys from env): | Variable | Meaning | |----------|---------| | `HAWK_CONFIG_DIR` | Override hawk config directory | +| `EYRIE_CONFIG_DIR` | Override Eyrie provider-state directory; takes precedence for `provider.json` | | `OPENAI_MODEL` | Override default OpenAI model | | `OLLAMA_BASE_URL` | Ollama server URL (also saved via `/config` for Ollama) | ## Related code -- Hawk: `internal/config/credentials_store.go`, `migrate_provider_secrets.go`, `internal/tool/safety.go`, `cmd/credentials.go` -- Eyrie: `credentials/`, `config/discovery_env.go`, `setup/setup_ui.go` +- Hawk: `internal/config/eyrie_engine.go`, `internal/tool/safety.go`, + `internal/storage/paths.go`, `cmd/credentials.go` +- Eyrie public host boundary: `engine/` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index acfd82af..2318d2e7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -11,6 +11,8 @@ Documents: - `hawk-dependency-rules.md` - import and ownership boundaries - `hawk-core-contracts-spec.md` - shared contracts layer and current status - `hawk-provider-abstraction.md` - provider/runtime abstraction design +- `hawk-eyrie-engine-migration.md` - implemented Hawk-face/Eyrie-engine boundary and submodule upgrade order +- `verification-status-2026-07-13.md` - dated verification evidence, current hardening, and release blockers - `hawk-review-verify-lifecycle.md` - review and verification lifecycle - `hawk-trace-event-model.md` - trace and audit event model - `hawk-contract-migration-inventory.md` - current shared-type usage and migration order diff --git a/docs/architecture/ecosystem-architecture.md b/docs/architecture/ecosystem-architecture.md index 1d6a95ac..c0329ed9 100644 --- a/docs/architecture/ecosystem-architecture.md +++ b/docs/architecture/ecosystem-architecture.md @@ -10,7 +10,7 @@ must not be treated as interchangeable. | Repository | Visibility target | Responsibility | Dependency rule | | --- | --- | --- | --- | | `hawk` | Public | CLI, local daemon, orchestration, policy and engine composition | Integration root; pins the seven engine repositories as submodules and published Go modules | -| `eyrie` | Public | Model/provider catalog, routing and client runtime | Library consumed by Hawk; no Hawk import | +| `eyrie` | Public | Provider engine: credentials, catalog, routing, transport and normalized streaming | Hawk consumes only its `eyrie/engine` host facade; no Hawk import | | `hawk-core-contracts` | Public | Shared Go domain contracts | Leaf contract module; must not import product implementations | | `tok` | Public | Token analysis and optimization engine | Independently releasable library and MCP server | | `trace` | Public | Trace and observability engine | Embeds only Hawk's supported CLI root boundary when built with Hawk | @@ -50,7 +50,7 @@ community skills ────────────────> Hawk extensio hawk-sdk-go ───────┐ hawk-sdk-python ───┴─ HTTP ─────> Hawk local daemon │ -hawk (integration root) ──────────────┼─ eyrie +hawk (integration root) ──────────────┼─ eyrie/engine external/ Gitlinks + Go modules ────┼─ core-contracts ├─ tok ├─ trace @@ -97,3 +97,6 @@ contracts repository merely to move files across a repository boundary. 5. Every submodule commit used by Hawk is publicly reachable and matches the corresponding module version. 6. Production Cloudflare bindings are explicit, typed and verified with a dry-run bundle. 7. Cross-repository contract drift and forbidden imports fail CI. +8. Hawk production code reaches provider credentials, catalogs and transport + only through `eyrie/engine`; Hawk-owned conversation and CLI schemas remain + separate. diff --git a/docs/architecture/hawk-architecture-v1-definition-of-done.md b/docs/architecture/hawk-architecture-v1-definition-of-done.md index 680e908d..b4b085df 100644 --- a/docs/architecture/hawk-architecture-v1-definition-of-done.md +++ b/docs/architecture/hawk-architecture-v1-definition-of-done.md @@ -35,7 +35,7 @@ Status note: - [x] no support repo imports `hawk/internal/*` - [x] no support repo imports removed `hawk/shared/types` - [x] no SDK/skills repo references support engines as primary dependencies -- [x] Hawk production code imports `eyrie/client` only at the transport adapter edge +- [x] Hawk production code imports Eyrie only through `eyrie/engine` ### `hawk-core-contracts` (implemented packages only) @@ -56,9 +56,9 @@ Adoption bar: ### Hawk integration seams -- [x] session persistence uses `hawk-core-contracts/tools`, not `eyrie/client` tool types +- [x] session persistence uses `hawk-core-contracts/tools`, not lower-level provider tool types - [x] review persistence and inspect/review bridge paths use neutral `review` / `verify` contracts -- [x] Hawk owns runtime transport DTOs in `internal/types` and adapts to `eyrie/client` at the edge +- [x] Hawk owns runtime DTOs in `internal/types` and translates them to `eyrie/engine` in `internal/engine` - [x] `hawk trace ...` remains a Hawk-mounted subcommand, not a competing product surface ### Enforcement @@ -106,7 +106,7 @@ Remove compatibility shims only when: From `hawk`: ```bash -make ecosystem-guard contracts-guard eyrie-client-guard peer-guard +make ecosystem-guard contracts-guard eyrie-client-guard eyrie-engine-guard peer-guard go test ./internal/testaudit/... -count=1 go test ./... -count=1 ``` diff --git a/docs/architecture/hawk-contract-migration-inventory.md b/docs/architecture/hawk-contract-migration-inventory.md index c4a5897c..0d86004d 100644 --- a/docs/architecture/hawk-contract-migration-inventory.md +++ b/docs/architecture/hawk-contract-migration-inventory.md @@ -57,17 +57,16 @@ Files: Assessment: - `internal/types/severity.go` now re-exports `hawk-core-contracts/types` -- `internal/types/client.go` is Hawk runtime/provider-facing and should remain Hawk-internal for now +- `internal/types/client.go` contains Hawk-owned conversation/runtime DTOs and + the small provider port needed by product integrations; it has no Eyrie imports - `internal/types/settings.go` is Hawk config-specific and should remain Hawk-internal ## Tool contract migration -### Source shape today +### Historical source shape -Current runtime source: - -- `eyrie/client.ToolCall` -- `eyrie/client.ToolResult` +Before the engine-boundary migration, runtime source types included +lower-level provider tool-call and tool-result DTOs. ### New neutral contract @@ -78,16 +77,17 @@ Added: ### First migration boundary -Hawk session persistence now uses the neutral tool contracts instead of persisting `eyrie/client` tool types directly. +Hawk session persistence now uses neutral tool contracts instead of persisting +lower-level provider types directly. ### Remaining migration - Hawk runtime now owns `internal/types.EyrieMessage` - Hawk runtime now owns tool call/result, response, usage, and stream DTOs in `internal/types` - Hawk runtime now owns chat options, response format, continuation config, tool choice, and tool definition DTOs in `internal/types` -- Hawk runtime now owns transport config via `internal/types.ClientConfig` - Hawk runtime now owns the provider seam via `internal/types.ChatProvider` -- `eyrie/client` is now adapted at the `internal/types` edge instead of leaking as Hawk runtime type aliases +- Hawk's `ChatClient` port is implemented by `internal/engine` using only + `eyrie/engine`; no production package imports a lower Eyrie package - future work should move trace/event/policy layers to consume neutral tool contracts where appropriate ## Review and verification contract migration diff --git a/docs/architecture/hawk-core-contracts-spec.md b/docs/architecture/hawk-core-contracts-spec.md index 89d195cb..e5fecf95 100644 --- a/docs/architecture/hawk-core-contracts-spec.md +++ b/docs/architecture/hawk-core-contracts-spec.md @@ -98,7 +98,8 @@ list in sync with the code (it is the inventory the dependency rules assume). - policy contracts now exist in `hawk-core-contracts/policy` - Hawk session persistence has started migrating to provider-neutral tool contracts - Hawk review storage and inspect/review bridge paths now consume neutral review/verify contracts -- Hawk transport config/provider seams are now Hawk-owned and adapted to `eyrie/client` at the edge +- Hawk runtime conversation DTOs and the `ChatClient` port are Hawk-owned and + translated to the stable `eyrie/engine` contract at the integration edge ## Versioning rule diff --git a/docs/architecture/hawk-current-vs-proposed.md b/docs/architecture/hawk-current-vs-proposed.md index 585a7408..4df64bfd 100644 --- a/docs/architecture/hawk-current-vs-proposed.md +++ b/docs/architecture/hawk-current-vs-proposed.md @@ -157,7 +157,7 @@ Owns: - CLI and command surface - session and workflow orchestration -- provider selection +- task-semantic model intent and explicit user preferences - tool execution policy - approval model - coordination of memory, context, tracing, review, and verification @@ -173,10 +173,18 @@ These exist to power Hawk and should remain replaceable, testable, and isolated. Purpose: -- provider execution runtime +- stable provider-engine host facade (`eyrie/engine`) +- credentials and provider authentication +- model discovery and capability catalog +- provider/deployment selection and infrastructure routing - request/response normalization - streaming, retry, timeout, fallback mechanics +Hawk composes this engine and owns every user-facing workflow around it. The +implemented boundary allows zero production imports of Eyrie's lower-level +packages; custom gateways are passed per Engine instance rather than installed +in global state. + #### `yaad` Purpose: diff --git a/docs/architecture/hawk-dependency-rules.md b/docs/architecture/hawk-dependency-rules.md index 1b3cff71..1c8aa49f 100644 --- a/docs/architecture/hawk-dependency-rules.md +++ b/docs/architecture/hawk-dependency-rules.md @@ -85,8 +85,17 @@ Anything used across repos must move to `hawk-core-contracts`. ### 4. Public integrations go through Hawk SDKs and skills must use Hawk public APIs, contracts, or plugin surfaces. -### 5. Provider logic stays behind runtime boundaries +### 5. Provider logic stays behind the Eyrie engine boundary Provider-specific code should not leak into memory, review, verify, or trace engines. +Hawk production code imports Eyrie only through `github.com/GrayCodeAI/eyrie/engine`. + +### 6. Hawk schemas stay Hawk-owned +Hawk's conversation persistence and CLI/JSON output are explicit projections, +not aliases or direct serialization of Eyrie engine DTOs. + +### 7. Engine configuration is instance-scoped +Hawk supplies effective custom gateways while constructing an Engine. Do not +mutate Eyrie process-global gateway state from product code. ## Current cleanup targets @@ -105,11 +114,9 @@ These were previously "ideas"; they are now implemented: ("Ecosystem Boundaries" section) - CI runs `scripts/check-ecosystem-boundaries.sh` in every support repo, and Hawk additionally runs `check-shared-types-imports.sh`, - `check-eyrie-client-imports.sh`, and `check-support-repo-coupling.sh` + `check-eyrie-client-imports.sh`, `check-eyrie-engine-boundary.sh`, and + `check-support-repo-coupling.sh` - `hawk-core-contracts` is kept minimal (leaf module, no external dependencies) -Still open: - -- the boundary guard scripts depend on `rg`; when `rg` is absent they pass - vacuously, so CI images must provide ripgrep (or the scripts should fall back - to `git grep`) +The Hawk boundary guards use ripgrep when available and fall back to recursive +grep, so a minimal CI image cannot turn a missing scanner into a vacuous pass. diff --git a/docs/architecture/hawk-ecosystem-summary.md b/docs/architecture/hawk-ecosystem-summary.md index 35a20fb2..e869d27e 100644 --- a/docs/architecture/hawk-ecosystem-summary.md +++ b/docs/architecture/hawk-ecosystem-summary.md @@ -67,7 +67,7 @@ bottom: shared foundations | Repo | Layer | Role | Depends on | Must not depend on | |---|---|---|---|---| | `hawk` | Product | CLI, orchestration, policy, execution control, public APIs | support engines, `hawk-core-contracts` | sibling company/platform repos in runtime paths | -| `eyrie` | Support engine | provider runtime, model execution, streaming, retries | `hawk-core-contracts` only if needed | `yaad`, `tok`, `trace`, `sight`, `inspect` | +| `eyrie` | Support engine | credentials, catalog, routing, model transport, normalized streaming | `hawk-core-contracts` only if needed | `yaad`, `tok`, `trace`, `sight`, `inspect` | | `yaad` | Support engine | memory, retrieval, persistence of long-lived context | `hawk-core-contracts` only if needed | `eyrie`, `tok`, `trace`, `sight`, `inspect` | | `tok` | Support engine | token budgeting, packing, truncation, context shaping | `hawk-core-contracts` only if needed | `eyrie`, `yaad`, `trace`, `sight`, `inspect` | | `trace` | Support engine | trace capture, replay, provenance, audit trail | `hawk-core-contracts` only if needed | `eyrie`, `yaad`, `tok`, `sight`, `inspect` | @@ -89,7 +89,7 @@ Owns: - session lifecycle - approval and permission policy - tool routing -- provider selection +- user model preference and task-semantic model intent - engine coordination - user-visible product APIs @@ -97,11 +97,17 @@ Owns: Owns: +- the sole provider-facing host facade (`eyrie/engine`) +- credential and provider-state lifecycle +- catalog discovery and concrete model/deployment selection - provider adapters - request/response normalization - streaming transport - retry and timeout logic +Hawk is Eyrie's composition root and user-facing presentation layer. Hawk +production packages do not import Eyrie below `eyrie/engine`. + ### `yaad` Owns: diff --git a/docs/architecture/hawk-eyrie-engine-migration.md b/docs/architecture/hawk-eyrie-engine-migration.md new file mode 100644 index 00000000..5835461d --- /dev/null +++ b/docs/architecture/hawk-eyrie-engine-migration.md @@ -0,0 +1,132 @@ +# Hawk–Eyrie Engine Migration + +## Target + +```text +User ──► Hawk product/agent ──► eyrie/engine ──► model providers +``` + +The implemented split is: + +```text +Hawk — product face Eyrie — provider engine + +CLI / TUI / SDK entrypoints + ├─ /config and model picker ───────────► engine control plane + │ ├─ OS credential store + │ ├─ catalog/discovery + │ └─ provider state + routing policy + └─ conversation + coding agent + ├─ history / WAL / resume + ├─ tools / permissions / policy + └─ Hawk ChatClient port ───────────► engine generate/stream + ├─ capability/model resolution + ├─ deployment routing + resilience + └─ normalized events/usage +``` + +Hawk remains authoritative for the coding-agent loop, tools, permissions, +project context, product memory, conversation history, WAL, checkpoints, and +resume/replay. Eyrie owns credentials, catalog discovery, capability matching, +provider/deployment routing, resilience, normalized streaming, usage, cost, +health, and provider telemetry. + +## Source workflow + +Standalone `eyrie/` is developed and tested first. Hawk then advances +`external/eyrie` to the exact Eyrie commit and verifies the clean submodule +checkout before integrating it. Published Hawk builds continue to use a tagged +Eyrie module version; `go.work` pins the submodule for local integration. + +Upgrade order is deliberate: + +1. implement and test the facade change in standalone Eyrie +2. create a reviewable Eyrie commit and ensure the revision is reachable +3. advance Hawk's `external/eyrie` gitlink to that exact commit +4. update Hawk's `go.mod` version after the Eyrie revision is published +5. run `go work sync`, then verify Hawk once through the submodule and once with + `GOWORK=off` + +Do not copy Eyrie source into Hawk or let a Hawk change depend on an unpinned +standalone checkout. + +## Migration rules + +1. New host-boundary code uses `github.com/GrayCodeAI/eyrie/engine`. +2. Hawk production code imports no Eyrie package below `eyrie/engine`. +3. Hawk expresses requirements and intent; Eyrie resolves infrastructure. +4. An exact user model does not silently fall back. +5. Eyrie emits tool requests; Hawk authorizes and executes tools. +6. Hawk has one authoritative product conversation store. +7. Secrets stay in Eyrie's credential store and never enter tool environments. +8. Custom gateways are Engine-instance configuration, not process-global state. +9. Hawk-owned persisted and CLI schemas do not mirror Eyrie DTOs implicitly. + +## Current slice + +- Eyrie provides the versioned host facade, provider-neutral DTOs, typed errors, + capability selection, normalized pull streaming, credential service, catalog + snapshots, injected credential/state paths, and explicit catalog-backed + deployment construction. +- Hawk's credential-save and production agent-chat paths now enter through + `eyrie/engine` via a Hawk-owned `ChatClient` adapter. +- Provider secrets are absent from Hawk's `Session`, `ChatService`, sub-session, + reattachment, and client-port surfaces. Native provider compaction uses + Eyrie's injected credential store and engine facade; Hawk receives only the + normalized summary and remains responsible for conversation mutation. +- Eyrie's control-plane facade now supplies credential resolution, safe masked + status, provider choices, and gateway configuration rows using its injected + credential store and state paths. Hawk owns their TUI/CLI presentation. +- Historical provider-state credentials are imported into the Engine's secret + store before an atomic sanitized rewrite. Every later provider-state write + uses the same sanitizer; Hawk protects the resolved + `EYRIE_CONFIG_DIR/provider.json` path from agent file and Bash access. +- Effective provider/model selection is an `eyrie/engine.Selection` contract; + Hawk's session factory, startup, live transport rebuild, and multi-agent + workers no longer depend on Eyrie's lower-level runtime selection DTOs. +- The model picker consumes display labels, ownership, serving gateway, + context, capabilities, pricing and price certainty from `engine.Model`; it + no longer reads or formats Eyrie's compiled catalog directly. +- `hawk models list --json` projects those DTOs into a stable Hawk-owned schema; + `--raw` exposes provider-native live metadata when present without coupling + automation to the engine DTO. +- Hawk retains task classification, workflow roles, cascade decisions and + health thresholds. Model lookup, aliases, provider ownership, defaults, + relative cost classes and preferred candidates now come from Eyrie's + host-neutral model-policy facade. +- The facade preserves advanced generation options and owns continuation; + Hawk's compatibility retry/rate-limit wrapper is bypassed for facade clients + so resilience is applied exactly once. +- Catalog administration, setup/diagnostics, review bridges, session creation, + parallel agents, custom gateways, and inline tool-call normalization all use + the engine boundary. The zero-exception rule is enforced by shell and AST + guards. +- Hawk supplies effective custom-provider settings in + `engine.Options.CustomGateways`; each Engine snapshots its own gateway set so + sessions and tests cannot leak configuration through globals. +- `hawk preflight` describes local readiness. A provider-scoped live model + fetch or `/config` validation is the optional live-verification step; local + readiness alone is not a remote authentication claim. +- Hawk's runtime conversation DTOs remain product-owned in `internal/types`; + the anti-corruption adapter in `internal/engine` translates them directly to + stable engine DTOs without importing a lower Eyrie transport package. +- Hawk now owns its persistent conversation graph under `internal/session`; + production sessions no longer mix Eyrie's generic DAG with Hawk WAL/session + persistence. + +## Completed removal gates + +Production catalog/setup/runtime/client compatibility imports are removed. +Lower Eyrie packages may appear only in tests that construct Eyrie-owned +fixtures. The provider circuit breaker, session API-key map, direct client +adapter, and mixed Eyrie DAG product path are also removed. Session file +readers remain backward-compatible for at least one release cycle. + +## Verification and release status + +See `verification-status-2026-07-13.md` for the current evidence ledger and +remaining blockers. In particular, the audited workspace's committed Eyrie +Gitlink, checked-out submodule and `go.mod` module revision do not match. The +source boundary is implemented locally, but that mismatch must be resolved and +verified in both workspace and `GOWORK=off` builds before the migration can be +called release-complete. diff --git a/docs/architecture/hawk-product-architecture.md b/docs/architecture/hawk-product-architecture.md index 9c59ccf9..13f61ace 100644 --- a/docs/architecture/hawk-product-architecture.md +++ b/docs/architecture/hawk-product-architecture.md @@ -6,6 +6,11 @@ Hawk is a model-agnostic AI coding agent CLI from GrayCodeAI. Hawk is the only primary product surface in the Hawk ecosystem. The support repos exist to power Hawk, not to compete with it as standalone products. +For model execution specifically: **Hawk is the face and composition layer; +Eyrie is the engine.** Hawk owns the conversation and product experience while +the `eyrie/engine` facade owns the complete provider path from credential and +catalog state through model selection and normalized generation/streaming. + ## Goals - Keep Hawk model agnostic. @@ -106,7 +111,7 @@ Hawk owns: - workflow control - tool routing - permission and policy enforcement -- provider selection +- user model preference and task-semantic model intent - engine coordination - public integration surfaces @@ -119,11 +124,15 @@ Hawk does not own: ## Engine responsibilities ### `eyrie` +- stable host control and generation facade (`eyrie/engine`) +- credential storage and safe credential status +- catalog discovery and model metadata +- concrete provider/deployment selection - provider adapters - request/response normalization - streaming - retries/timeouts/fallbacks -- low-level provider registry and compatibility logic behind Hawk-owned transport adapters +- low-level provider registry and compatibility logic behind the engine facade ### `yaad` - session and long-term memory @@ -157,7 +166,8 @@ Hawk does not own: ## Primary runtime flow 1. User invokes `hawk`. -2. Hawk loads config, policy, provider settings, and workspace state. +2. Hawk loads product settings, policy, and workspace state, then creates an + Eyrie Engine with effective per-instance custom gateway settings. 3. Hawk creates or resumes a session. 4. Hawk asks `tok` for context assembly. 5. Hawk asks `yaad` for relevant memory. @@ -167,6 +177,12 @@ Hawk does not own: 9. Hawk invokes `inspect` when verification should run. 10. Hawk persists results and returns output to the user. +At step 6, Hawk passes intent and Hawk-owned conversation DTOs through its +adapter. Eyrie loads provider/catalog/credential state, resolves the gateway, +and returns normalized events. No production Hawk package imports a lower +Eyrie package, and no Eyrie engine DTO is used as Hawk's persistent or CLI +schema. + ## Implementation phases ### Phase 1 @@ -195,9 +211,10 @@ Status: - formalize provider, trace, review, and verify integration points Status: -- substantially completed -- Hawk now owns runtime DTOs, transport config/provider seams, and review/verify product-boundary contracts -- direct `eyrie/client` usage is restricted to adapter edges and enforced by shell guards plus meta-audit tests +- completed for the local runtime boundary +- Hawk owns runtime DTOs and review/verify product-boundary contracts +- Hawk's `ChatClient` anti-corruption port translates only to `eyrie/engine` +- all lower-level Eyrie production imports are forbidden by shell guards and meta-audit tests ### Phase 5 - align SDKs and skills to Hawk public interfaces only diff --git a/docs/architecture/hawk-provider-abstraction.md b/docs/architecture/hawk-provider-abstraction.md index e81268d5..6ac4a47e 100644 --- a/docs/architecture/hawk-provider-abstraction.md +++ b/docs/architecture/hawk-provider-abstraction.md @@ -8,21 +8,27 @@ That means Hawk should support multiple providers without leaking provider-speci ## Design principle -Provider-specific code lives behind runtime adapters, primarily in `eyrie`. +Provider-specific code lives behind Eyrie's stable `eyrie/engine` host facade. +Hawk is the face and composition root; Eyrie is the engine. Hawk decides: -- which provider to use -- whether fallback is allowed - what capability the task needs +- the semantic intent (`fast`, `balanced`, `reasoning`, `economical`) +- whether an exact user-selected model may fall back `eyrie` handles: +- capability-to-model resolution +- provider and deployment selection +- health-aware infrastructure routing and fallback - request translation - streaming normalization - tool-call normalization - retries and backoff - provider capability differences +- credential storage, import, and sanitized provider state +- catalog/cache ownership and provider-scoped live discovery ## Required capabilities @@ -36,7 +42,8 @@ Hawk decides: ## Hawk-facing abstraction -Hawk should depend on a capability-based interface, not a vendor-specific client. +Hawk depends on its small `ChatClient` product port and adapts it only to +`eyrie/engine`, never to a vendor-specific or lower-level Eyrie client. Example concerns: @@ -52,7 +59,15 @@ Example concerns: - no direct vendor SDK imports in unrelated Hawk packages - no provider-specific branches inside review/verify logic - no model-specific assumptions inside session persistence -- keep fallback/routing policy inside Hawk orchestration, not inside engines unrelated to runtime +- keep task-semantic policy inside Hawk orchestration +- keep provider/deployment routing, health, retry, and fallback inside Eyrie +- Hawk production integrations use `github.com/GrayCodeAI/eyrie/engine` +- direct imports of lower Eyrie packages are forbidden and CI-enforced +- custom gateway settings enter as `engine.Options.CustomGateways` and are + isolated per Engine instance +- Hawk-owned JSON, session, and conversation schemas are explicit projections; + they never become aliases of engine DTOs +- local preflight readiness and remote live verification are distinct states ## Future extension diff --git a/docs/architecture/hawk-repo-roles.md b/docs/architecture/hawk-repo-roles.md index 265d7be0..0ecea798 100644 --- a/docs/architecture/hawk-repo-roles.md +++ b/docs/architecture/hawk-repo-roles.md @@ -14,6 +14,7 @@ Owns: - tool execution flow - policy and permissions - engine coordination +- user-facing model/configuration presentation and stable CLI schemas Hawk is the only primary product surface. Users interact with Hawk, not with six separate end-user products. @@ -21,7 +22,10 @@ separate end-user products. ## Support engines ### `eyrie` -Hawk runtime and provider execution engine. +Hawk provider engine. Its public host boundary is `eyrie/engine`, which owns +credentials, provider state, catalog discovery, model/deployment selection, +transport, resilience, and normalized generation/streaming. Hawk production +code has zero imports of Eyrie's lower-level packages. ### `yaad` Hawk memory engine. diff --git a/docs/architecture/plan.md b/docs/architecture/plan.md index 9357b132..5c4a3027 100644 --- a/docs/architecture/plan.md +++ b/docs/architecture/plan.md @@ -143,7 +143,7 @@ Session ## Dependencies -- **eyrie:** LLM provider runtime (external submodule) +- **eyrie:** LLM provider engine behind `eyrie/engine` (external submodule) - **yaad:** Graph-based persistent memory (external submodule) - **tok:** Tokenizer, compression (external submodule) - **hawk-core-contracts:** Shared types (external submodule) diff --git a/docs/architecture/spec.md b/docs/architecture/spec.md index 3812c69d..b232ad50 100644 --- a/docs/architecture/spec.md +++ b/docs/architecture/spec.md @@ -43,7 +43,7 @@ The `internal/` directory SHALL contain the following packages: | `tool/` | 40+ built-in tools (file edit, git, codegen, spec tools, etc.) | | `permissions/` | Guardian, rules DSL, boundary checker | | `plugin/` | Skills loader, registry, auto-skill, bundled skills | -| `config/` | Settings, env manager, migration | +| `config/` | Product settings, Eyrie composition, state migration | | `session/` | SQLite persistence, search, export, replay | | `hooks/` | Event-driven plugin system | | `mcp/` | Model Context Protocol client/server | @@ -57,7 +57,7 @@ The `internal/` directory SHALL contain the following packages: | `bridge/` | Integrations (inspect, sight, trace, sessioncapture) | | `prompt/` | Identity preamble | | `prompts/` | Modular template system (role, execution, tools, etc.) | -| `provider/` | Provider routing, model selection | +| `provider/` | Task-semantic roles, cascade intent, and product policy | | `rules/` | Rules DSL engine | | `system/` | Bus, shutdown, retention, cron, staleness | | `storage/` | State directory management | @@ -72,7 +72,7 @@ The `internal/engine/` package SHALL contain the following sub-systems: |------------|---------| | `stream.go` | The agent loop (agentLoop) - main orchestration | | `session.go` | Session struct and sub-services | -| `chat_service.go` | LLM transport, rate limit, retry, compact | +| `chat_service.go` | Hawk ChatClient port, engine adapter coordination, compact | | `safety/` | Permission engine, trust tiers, spec gate | | `compact/` | Context compaction (collapse, micro, smart, truncate) | | `ctxmgr/` | Context providers, packing, visualization | @@ -89,6 +89,22 @@ The `internal/engine/` package SHALL contain the following sub-systems: | `validation/` | Lint loop, test loop | | `scaffold/` | Skill registry, few-shot, learned skills | +Provider boundary invariant: + +```text +Hawk CLI/TUI + conversation + tools + | + Hawk-owned ports/DTOs + | + v + eyrie/engine + credentials -> catalog -> routing -> generate/stream +``` + +No production Hawk package may import a lower Eyrie package. Custom gateways +are supplied per Engine instance, and Eyrie DTOs are not Hawk persistence or +CLI output schemas. + ### REQ-4: Agent Loop Lifecycle The agent loop in `stream.go` SHALL execute the following phases: @@ -111,7 +127,7 @@ The agent loop in `stream.go` SHALL execute the following phases: 6. Refresh Yaad memories 7. Build LLM ChatOptions (system prompt, tools, model) 8. Inject ephemeral context (beliefs, matched skills, spec stage) -9. Execute LLM call via ChatService.Stream() +9. Execute the LLM call via `ChatService.Stream()` and the `eyrie/engine` adapter 10. Process response (tool calls, messages, cost tracking) 11. Check termination conditions diff --git a/docs/architecture/verification-status-2026-07-13.md b/docs/architecture/verification-status-2026-07-13.md new file mode 100644 index 00000000..8be56211 --- /dev/null +++ b/docs/architecture/verification-status-2026-07-13.md @@ -0,0 +1,251 @@ +# Ecosystem Verification Status — 2026-07-13 + +## Verdict + +The audited revision set now has a release-aligned Hawk-face/Eyrie-engine +boundary. Local gates and Eyrie's hosted release gates are green; the final +Hawk revision still requires successful hosted CI before the ecosystem can be +declared production-ready. + +The architecture and focused hardening tests support this responsibility split: + +```text +users and SDKs + | + v +Hawk product face + CLI / daemon / agent loop / sessions / tools / permissions / product schemas + | + v +Eyrie engine facade + credentials / provider state / catalog / route resolution / transport / + normalized streams / provider resilience / provider telemetry + | + v +model providers +``` + +The prior Eyrie release-parity mismatch is resolved by v0.2.1. Final Hawk +hosted CI on the reviewable commit remains the publication gate for a +whole-ecosystem production-readiness claim. + +## Verified responsibility boundary + +Hawk owns the user-facing product and orchestration concerns: + +- CLI, TUI, daemon and SDK entrypoints +- coding-agent loop, tool authorization, permissions and project policy +- product session history, WAL, checkpoints, resume and replay +- task-semantic model intent and user-visible configuration presentation +- Hawk-owned persistence, runtime and public response schemas + +Eyrie owns the provider engine concerns behind `eyrie/engine`: + +- credentials, provider state and safe status projection +- catalog discovery, model capabilities and concrete route resolution +- provider adapters, transport and normalized generation/streaming +- provider retry, timeout, fallback, health and telemetry behavior + +Hawk production code is guarded against lower-level Eyrie imports. Hawk may +record product-level latency and usage, but it must not reimplement provider +routing or apply a second resilience policy to an Eyrie-facade request. + +## Hardening present in the audited workspace + +### Resolved route attribution + +- Hawk's provider-neutral response and stream DTOs preserve Eyrie's resolved + route. +- `route_selected` and `route_changed` events update the effective provider and + model used by traces, hooks, usage events and cost accounting. +- Cost updates change the effective model and apply token/cost totals under one + lock, preventing a routed fallback from being billed as the requested model. +- A repeated terminal usage payload is de-duplicated without dropping distinct + continuation-segment usage. + +Focused blocking, streaming, partial-route, usage and cost tests passed, +including the focused race checks recorded during this audit. + +### One provider-resilience layer + +- Hawk's `ChatClient` compatibility port has an optional resilience-ownership + capability. The Eyrie facade adapter advertises that it owns provider + resilience; legacy injected clients do not. +- For an Eyrie facade stream, Hawk delegates the initial request exactly once + and bypasses Hawk's compatibility call retry/rate limiting, transient stream + reopen, thinking-only non-streaming fallback and synthetic `max_tokens` + continuation. +- Legacy clients retain those compatibility behaviors. This preserves tests and + third-party adapters without wrapping production Eyrie calls in a second + retry policy. +- Hawk remains responsible for authorizing tool calls and persisting product + conversation messages regardless of which client owns resilience. + +Focused engine tests, focused race tests, `go vet` and the Hawk/Eyrie boundary +guards passed for this split. + +### Lossless Eyrie selection and provider-state migration + +- Existing Eyrie active selection is authoritative over Hawk's legacy + `provider` and `model` settings. +- A legacy provider/model pair is validated and written as one selection before + its source fields are removed. Rejected selections leave the source settings + untouched for repair instead of silently discarding them. +- Historical provider-state secrets are imported into Eyrie's secret store + before an atomic sanitized rewrite. +- Eyrie accepts the historical decode-only `version` key as well as canonical + `_version`, while still rejecting unknown fields, trailing JSON, conflicting + versions and unsupported future versions. + +Standalone Eyrie passed the full Go test suite, the full race-enabled suite, +`go vet`, both ecosystem boundary guards and `git diff --check` in this audit. + +### Hawk daemon readiness and durable sessions + +- `GET /v1/ready` returns success only when a session factory exists and + Eyrie's local preflight reports `Ready=true`. A missing or failed probe + returns 503 with a reason; factory wiring alone is not provider readiness. +- `POST /v1/chat` without a session ID creates a random durable ID, persists the + transcript and returns the same ID in the JSON response and + `X-Hawk-Session-ID` header. +- A request with a session ID requires an existing durable session, inherits + its transcript and metadata, appends the new turn and persists under the same + ID. Invalid IDs return 400 and missing sessions return 404. +- SSE responses expose the session ID header, persist the conversation and put + the session ID and usage in the final `done` event. +- Corrupt or unreadable session state is reported as an internal persistence + failure instead of being misclassified as a missing session. Fixed lock + striping serializes same-session operations without letting arbitrary + client-supplied IDs grow a lifetime lock map. +- Hawk applies and persists the requested agent persona. Session CWD is + validated and canonicalized as durable metadata; daemon tools intentionally + continue to use the daemon's startup CWD rather than an unsafe process-wide + directory change. + +Focused daemon/session and command tests passed in isolated directories, and +the daemon package passed its race-enabled test run. This preflight is a local +configuration/readiness check, not proof of live remote-provider authentication. + +### Hawk Cloud usage queue + +- The idempotency marker insert and monthly rollup update execute in one D1 + batch. The rollup uses SQLite `changes()` from the marker insert, so a + duplicate delivery cannot increment the aggregate twice. +- A failed message is retried rather than acknowledged at an application-local + retry limit. Cloudflare Queue `max_retries` and dead-letter configuration are + the single terminal-delivery policy, avoiding silent event loss. +- Retry backoff is capped at 12 hours. + +The queue-focused tests, the then-current full Hawk Cloud test suite, +type-check, formatting check, Wrangler type generation/dry run and a direct +SQLite idempotency check passed during the audit. + +### CI gates + +- Hawk's Docker Trivy step now fails on fixable high or critical image findings + (`exit-code: '1'`). +- Hawk Cloud CI now generates a V8 JSON coverage report, fails if the report or + any metric is missing, and enforces all four metric floors. +- The honest initial coverage floors are 30% statements, 20% branches, 40% + functions and 35% lines. The final measured local baseline was 32.65%, + 23.84%, 40.64% and 36.37%, respectively. Sixty percent remains a ratchet + target, not a description of current coverage. + +These are locally verified workflow/configuration changes. A successful remote +CI run on the final published commits is still required. + +### Community-skill corpus hardening + +- All 12,167 discovered skills pass the full-corpus validator. +- Every warning category is at zero: broken internal references, path traversal, + oversized files and `SKILL.md` bodies, script shebang and executable-bit + defects, excess tags, overlong descriptions and uncategorized warnings. +- The checked-in warning budget is zero per category. CI requires an exact + match, new categories start at zero and the budget must never increase. +- The local-reference validator covers every Markdown file in each skill, + applies exact-case and skill-root containment checks, and ignores code spans, + fenced code, anchors and external URLs. +- Safe, dry-run-first cleanup and oversized-body migration tools preserve + readable content and frontmatter while moving large bodies into ordered + progressive-disclosure references. The size allowlist now has zero + exceptions. + +The current community repository suite passed 303 tests, and the full validator +reported 12,167 passed, zero failed and zero warnings. Ruff, boundary and +registry checks remain part of the repository gate; this local result does not +replace final remote CI. + +## Verification evidence captured + +| Scope | Evidence recorded during this audit | Status | +| --- | --- | --- | +| Standalone Eyrie | full tests, full race tests, vet, boundary guards, diff check | Passed | +| Hawk route/config seams | focused tests and focused race checks | Passed | +| Hawk daemon/session seams | focused daemon, session and command tests; daemon race test | Passed | +| Support Go repos | full tests and vet for `hawk-core-contracts`, `inspect`, `sight`, `tok`, `yaad`, `trace`, `hawk-mcpkit` and `hawk-sdk-go` | Passed | +| Python SDK | 288 tests, Ruff check/format and strict mypy | Passed | +| Hawk Cloud queue | focused and full tests, type-check, format, Wrangler checks, direct SQLite check | Passed | +| Hawk full integration | isolated full tests, full race tests, vet and all architecture guards against the completed workspace | Passed | +| Published release graph | Eyrie v0.2.1 gitlink/module parity; two full Hawk passes in workspace and `GOWORK=off` modes | Passed locally; final Hawk hosted CI pending | +| Community skills | 303 tests; 12,167 skills passed; zero failures and zero warnings; Ruff, boundary and registry gates | Passed locally with a zero-warning budget | +| Adjacent GrayCode Core | forced 266-test run, lint, type-check, production build, Hawk Cloud contract comparison and package audit | Passed; not a runtime dependency | + +Passing a row describes the recorded local evidence only. It does not replace a +clean checkout, public revision reachability, signed release or remote CI run. + +The final security sweep found no reachable Go vulnerabilities in Hawk or +Eyrie, no npm audit findings in Hawk Cloud or GrayCode Core, and no known Python +SDK dependency vulnerabilities. Hawk's verbose Go scan did note +`GO-2026-5932` at module level because `golang.org/x/crypto` contains the +unmaintained `openpgp` package; no Hawk import or call reaches that package and +the advisory has no fixed module version. GrayCode Core remains outside the +Hawk runtime graph; its only checked integration here is the versioned Hawk +Cloud API contract. + +## Release status + +### Eyrie release graph is aligned + +The release sequence completed without weakening the parity guard or copying +local Eyrie source into Hawk: + +| Source | Revision | +| --- | --- | +| Published Eyrie module | `v0.2.1` | +| Published tag and module origin | `2e5ec4e3bb03705d5a09792009f113625258fc5a` | +| Hawk `external/eyrie` checkout and gitlink candidate | `2e5ec4e3bb03705d5a09792009f113625258fc5a` | + +The Eyrie v0.2.1 GitHub Release is published. Its pull-request gates passed +tests with the race detector, coverage, lint, vet, module hygiene, security, +four fuzz targets and all configured cross-platform builds. The module checksum +is recorded in Hawk's `go.sum`, and `go mod download -json` resolves v0.2.1 to +the same commit as the clean submodule checkout. + +Hawk then passed two complete shuffled test runs through the workspace checkout +and two through `GOWORK=off`, including the public-module build. The exact +workspace race-and-coverage run passed at 69.0% total statement coverage. Vet, +lint, formatting, all architecture guards, module verification and both +workspace/module vulnerability scans passed. + +### Final Hawk hosted CI remains + +The completed local change set must still be committed and exercised by Hawk's +hosted pull-request CI. A production-ready claim depends on those hosted test, +race, coverage, boundary, security, public-module and submodule-parity jobs +passing on the exact reviewable Hawk revision. + +## Production-readiness exit criteria + +The ecosystem can make a production-ready claim only after all of the following +are true in one reproducible final revision set: + +- Eyrie Gitlink, clean checkout and published Go module resolve to the same + public commit. +- Hawk passes full, race, vet, boundary and release-parity verification through + both the local submodule and `GOWORK=off` module graph. +- Hawk Cloud tests, coverage gate, type-check, deployment dry run and security + scans pass in remote CI. +- Community-skill validation remains at zero warnings in every category, with + no size exceptions, in the final clean revision. +- The final repository set is clean, reviewable and tagged; no required behavior + depends on uncommitted local patches. diff --git a/docs/design/HAWK-CLOUD-SAAS.md b/docs/design/HAWK-CLOUD-SAAS.md index dd436a18..c139b759 100644 --- a/docs/design/HAWK-CLOUD-SAAS.md +++ b/docs/design/HAWK-CLOUD-SAAS.md @@ -68,8 +68,8 @@ once and shows how both consume it. `sync.Map` (`daemon.go:35`). Net-new: a routing layer that maps tenant → session → execution worker. 4. **Billing / credit system.** Meter token spend and sandbox-minutes per tenant, - enforce hard limits, and bill. Reuse eyrie's existing per-key budget machinery - (`eyrie/client/budget_provider.go`). + price normalized usage with Hawk Cloud's versioned server catalog, enforce + hard limits, and bill from the authoritative cloud ledger. 5. **Team workspaces & conversation sharing.** Shared, permissioned session state; read-only and continue-able shares. 6. **Org RBAC** with Member / Admin / Owner tiers, invitations, and org-scoped @@ -87,8 +87,10 @@ once and shows how both consume it. privacy-first posture (`TOP20_COMPARISON.md:20`) is preserved — cloud is opt-in. - **Not** building our own microVM hypervisor. We integrate E2B/Daytona (build-vs-buy in §6), not write Firecracker orchestration from scratch. -- **Not** an LLM gateway rewrite. eyrie remains the model runtime; the plane calls - eyrie. The plane does not re-implement routing, caching, or provider adapters. +- **Not** an LLM gateway rewrite. Eyrie remains the provider engine behind + Hawk's `eyrie/engine` integration; the plane calls a Hawk agent worker, which + composes Eyrie. The plane does not re-implement routing, caching, or provider + adapters. - **Not** SSO/SAML in P0 (deferred to P2; OIDC social login + API keys first). - **Not** on-prem/self-hosted enterprise distribution in P0 (P2). - **Not** mobile-native apps (the browser UI is responsive; that suffices initially). @@ -141,9 +143,9 @@ middleware (`daemon.go:185`) and `routes()` table (`daemon.go:174-183`). **Identity service** — orgs, users, memberships, roles, API keys, OAuth tokens. Net-new. Wraps/extends `internal/auth` (see §4). -**Billing service** — credit ledger, metering ingestion, hard-limit enforcement, -invoicing. Built on eyrie's `BudgetStore` / `BudgetProvider` -(`eyrie/client/budget_provider.go`). +**Billing service** — credit ledger, metering ingestion, server-side pricing, +hard-limit enforcement, and invoicing. Hawk Cloud owns this authority; Eyrie +supplies normalized model and token usage through Hawk's Engine boundary. **Workspace service** — workspace membership, shared session state, share links, permission checks for view/continue. @@ -192,13 +194,14 @@ credit_ledger(id, org_id, ts, delta_credits, reason, ref_session_id, ref_meter_i -- append-only; balance = SUM(delta_credits) meter_events(id, org_id, workspace_id, session_id, ts, kind, - tokens_in, tokens_out, model, sandbox_ms, cost_usd, credits) - -- kind ∈ {llm, sandbox}; cost_usd via eyrie ActualCostUSD() + tokens_in, tokens_out, model, sandbox_ms, cost_usd, credits, + pricing_catalog_version) + -- kind ∈ {llm, sandbox}; cost_usd is priced by Hawk Cloud ``` -`meter_events.cost_usd` is computed with eyrie's existing -`ActualCostUSD(model, usage)` (`eyrie/client/budget_provider.go:127`), so the -dollar→credit conversion lives in one place. +`meter_events.cost_usd` is computed from normalized model/token dimensions by +Hawk Cloud's versioned pricing catalog. Client-side estimates may be displayed +or audited, but never affect credits or invoices. ### 3.3 API Surface @@ -308,7 +311,7 @@ This is the crux: **what is reusable today vs. net-new.** | Autonomy / permission gating | `daemon.go:286-298` (`PresetConfig`, `NeedsPermission`) | Server-side auto-approval policy already exists for non-interactive runs; cloud reuses it per-workspace policy. | | Auth primitives | `internal/auth/auth.go` (`TokenStore`, `SecureStorage`), `device_flow.go` (full RFC 8628) | Device-grant **client** is done. `SecureStorage` (macOS keychain + file fallback, `auth.go:54-121`) stays for local credential caching of cloud tokens. `GenerateNonce` (`auth.go:124`) for OAuth state/PKCE. | | Constant-time key compare | `daemon.go:207-224` | API-key verification logic carries over (but keys move to hashed storage; see net-new). | -| eyrie budget/metering | `eyrie/client/budget_provider.go` (`BudgetProvider`, `BudgetStore`, `WithVirtualKey`/`VirtualKeyFromContext`, `ActualCostUSD`, `MemoryBudgetStore`); `eyrie/client/usage_limit.go`; `eyrie/storage/budgets.go` | This **is** the billing engine's enforcement layer. Map `virtual key = org credit pool`; implement a Postgres-backed `BudgetStore` (interface already abstracted to "primitive types so both in-memory and DB stores work", `budget_provider.go:36-53`). Credit checks ride the existing `CheckBudget`/`RecordUsage` path. | +| Eyrie normalized usage | `eyrie/engine` response and stream usage DTOs through Hawk's adapter | Reuse model/token dimensions as metering input. Do not import lower Eyrie packages or trust client-computed prices for billing. | | Sandbox executor interface | `internal/sandbox/container.go:18-21` (`containerExecutor`: `Exec`, `Running`) | `CloudSandbox` implements the same interface → drop-in. Callers don't know if they're on local Docker or a cloud microVM. | | Sandbox lifecycle manager | `internal/sandbox/snapshot_sandbox.go:52-228` (`Create/Pause/Resume/Snapshot/Restore/List/Cleanup`) | Existing pause/resume/snapshot semantics map cleanly onto E2B/Daytona pause+snapshot APIs; the manager abstraction guides the `CloudSandbox` API shape. | | Messaging gateways | `internal/daemon/gateway.go`, `telegram.go`, `discord.go`, `slack.go` | Already forward to `/v1/chat` via `forwardToHawk` (`gateway.go:17`) with bearer auth. In cloud they forward to the tenant-scoped chat endpoint with the org's key — minimal change. | @@ -358,7 +361,9 @@ This is the crux: **what is reusable today vs. net-new.** - **M0.4** Session Router + durable session store replacing the in-memory `sync.Map`; tenant-scoped `POST /v1/workspaces/{ws}/chat` (SSE reused verbatim). - **M0.5** Single-region Agent Worker pool (1 session/worker, worktree isolation). - **M0.6** `CloudSandbox` (E2B *or* Daytona — pick one) implementing `containerExecutor` (`container.go:18`). -- **M0.7** Metering: emit `meter_event`s using eyrie `ActualCostUSD`; Postgres `BudgetStore` behind eyrie `BudgetProvider`; hard credit floor returns HTTP 402. +- **M0.7** Metering: ingest normalized model/token dimensions, price them with + the versioned Hawk Cloud catalog, persist the Postgres credit ledger, and + return HTTP 402 at the hard credit floor. - **M0.8** Minimal web UI (single `go:embed` page) for chat + diff view. **Exit criteria:** a paying single team can log in via OAuth, run hawk against a @@ -394,7 +399,7 @@ credits hit zero. | Identity / OAuth server | **Buy** (Ory Hydra/Kratos, Auth0, or WorkOS) for P0; revisit P2 | Writing a compliant OAuth2/OIDC AS is risky. Ory is Apache-2.0 (self-hostable, privacy-aligned). WorkOS/Auth0 are SaaS — faster but introduce a third party in the auth path (weigh against privacy posture). Prefer **Ory self-hosted** to keep the privacy-first promise. | | Billing / payments | **Buy** Stripe for payment + invoicing; **build** the credit ledger | Never build a card processor. The ledger and metering are ours (and partly exist in eyrie). Stripe SDK is permissive. | | Database | **Buy** managed Postgres | Standard. | -| Metering enforcement | **Build on existing** eyrie `BudgetProvider`/`BudgetStore` | Already in-repo (`eyrie/client/budget_provider.go`); only the Postgres `BudgetStore` impl is new. | +| Metering enforcement | **Build in Hawk Cloud** on normalized Engine usage | Billing authority must be server-owned and versioned; lower Eyrie packages remain engine implementation details. | | Web UI framework | **Build** small React SPA, `go:embed` served (Aider/OpenHands pattern) | Keeps deployment a single binary; matches `TOP20_COMPARISON.md:31`. | | IDE protocol | **Adopt** ACP (Agent Client Protocol) | Industry trajectory (Gemini CLI, Zed, JetBrains), `TOP20_COMPARISON.md:32,36`. Avoid bespoke per-IDE protocols. | @@ -452,7 +457,7 @@ erode that. Daytona = richer dev-environment model. Which maps better onto hawk's pause/resume/snapshot (`snapshot_sandbox.go`)? 3. **Credit unit:** bill in $-equivalent credits (1 credit = $0.01?) covering both - LLM cost (`ActualCostUSD`) and sandbox-minutes — what's the blended margin? + server-priced LLM usage and sandbox-minutes — what's the blended margin? 4. **BYO keys vs. managed keys:** if an org brings its own provider keys, do their LLM tokens still consume credits (we only charge sandbox/orchestration), or run free? Affects eyrie provider-config plumbing (`TOP20_COMPARISON.md:96`). diff --git a/docs/ecosystem-message-flow.md b/docs/ecosystem-message-flow.md index 31014f00..e4dd388a 100644 --- a/docs/ecosystem-message-flow.md +++ b/docs/ecosystem-message-flow.md @@ -26,9 +26,10 @@ User prompt (TUI or hawk exec) │ ▼ ┌───────────────────┐ ┌─────────────┐ -│ eyrie ChatClient │────►│ LLM API │ catalog, keychain, routing -│ (stream loop) │ │ (provider) │ -└─────────┬─────────┘ └─────────────┘ +│ Hawk ChatClient │────►│ eyrie/engine│ catalog, credentials, routing +│ port + adapter │ │ generate/ │────► provider API +└─────────┬─────────┘ │ stream │ + └─────────────┘ │ ▼ Tool calls (Read, Edit, Bash, CoreMemory*, …) @@ -49,7 +50,10 @@ User prompt (TUI or hawk exec) ### 1. Session start (`hawk` or `hawk exec`) -- **eyrie**: Loads `~/.hawk/provider.json`, keychain credentials, and `~/.eyrie/model_catalog.json`. Builds `ChatClient` via `engine.BuildChatClient`. +- **eyrie**: The Hawk composition root creates an `eyrie/engine.Engine` with + Eyrie-owned state paths, an injected secret store, and per-engine custom + gateway metadata. The engine loads provider state and the model catalog, then + builds transport behind Hawk's `ChatClient` port. - **yaad**: `configureSession` creates `YaadBridge` → opens `~/.yaad/data/yaad.db`. If missing, hawk runs without persistent memory. - **tok**: No startup step — linked at compile time. @@ -64,7 +68,8 @@ User prompt (TUI or hawk exec) Each turn: 1. **yaad** — recall memories matching the latest user message (token budget ~2000). -2. **eyrie** — `client.Chat` / streaming with tool definitions from `tool.EyrieTools`. +2. **eyrie** — Hawk's adapter calls engine generate/stream with Hawk-owned tool + definitions; Eyrie normalizes provider events and tool requests. 3. Tools run with `YaadBridge` in context for `CoreMemory*` tools. 4. **yaad** — sleeptime consolidation, skill distillation, auto-remember after turns. @@ -97,3 +102,8 @@ hawk yaad # inspect memory graph Pinned support-repo submodules live under `external/{eyrie,yaad,tok}` and are wired via root `go.work`. + +Production Hawk code imports Eyrie only through `eyrie/engine`. Conversation +history, WAL/resume, permissions, and tool execution remain in Hawk; provider +credentials, discovery, selection, transport, resilience, and normalized +streaming remain in Eyrie. diff --git a/docs/plans/hawk-contracts-migration-backlog.md b/docs/plans/hawk-contracts-migration-backlog.md index ead280dc..70fbb5b2 100644 --- a/docs/plans/hawk-contracts-migration-backlog.md +++ b/docs/plans/hawk-contracts-migration-backlog.md @@ -36,9 +36,11 @@ These items are already completed in the current workspace. - added runtime/session conversion helpers - added `hawk-core-contracts/tools/tool_test.go` - centralized message slice conversion in `hawk/internal/session` -- removed direct `eyrie/client` message reconstruction from Hawk cmd/session restore paths -- replaced the `internal/types.EyrieMessage` alias with a Hawk-owned runtime DTO -- added explicit `internal/types` adapters between Hawk runtime messages and `eyrie/client` +- removed direct lower-level provider message reconstruction from Hawk + cmd/session restore paths +- replaced the provider-owned message alias with a Hawk-owned runtime DTO +- added explicit Hawk-owned runtime DTOs; the final boundary translates them + through `eyrie/engine` ### Event contract migration - added `hawk-core-contracts/events` @@ -57,7 +59,7 @@ These items are already completed in the current workspace. - added `check-ecosystem-boundaries.sh` - added `check-eyrie-client-imports.sh` - wired both guards into `Makefile` and CI -- wired the `eyrie/client` boundary guard into `Makefile` and CI +- wired the Eyrie engine-boundary guards into `Makefile` and CI - added a legacy import guard so the removed `shared/types` path cannot return - extended the ecosystem boundary guard to scan sibling engine repos when present locally - updated docs across Hawk, sight, inspect, and external workspace copies @@ -95,23 +97,23 @@ Local state: Still external: - confirm released module versions used by Hawk match the merged contract changes -### 3. Decide whether Hawk session/API message DTOs should fully stop using `eyrie/client` types +### 3. Remove Hawk production dependency on lower Eyrie packages Current state: - session persistence uses neutral tool contracts - Hawk now owns the runtime message DTO in `internal/types.EyrieMessage` - Hawk now owns runtime tool call/result DTOs in `internal/types` - Hawk now owns runtime response/usage/stream DTOs in `internal/types` - Hawk now owns runtime chat options, response format, tool choice, continuation config, and tool definition DTOs in `internal/types` -- Hawk now owns transport client construction config in `internal/types.ClientConfig` - Hawk now owns the transport-provider seam in `internal/types.ChatProvider` -- `eyrie/client` is now adapted only at the `internal/types` edge and a few provider-registry helper paths +- Hawk session, review, setup, catalog, diagnostics, and custom-provider paths + now enter through `eyrie/engine` +- production imports of every lower Eyrie package are zero - cmd/session restore paths now go through centralized `session.ToRuntimeMessages` and `session.FromRuntimeMessages` Decision: -- completed: Hawk now owns the transport config/provider seam -- keep direct `eyrie/client` usage limited to adapter implementations and provider-registry integration only - -This should be a deliberate decision, not drift. +- completed: Hawk owns the product DTO/port seam and Eyrie owns the engine, + provider, credential, catalog, routing, resilience, and normalization layers +- keep the zero-exception boundary enforced; lower packages are test-fixture-only ## Later @@ -177,7 +179,8 @@ Do not do these without a separate decision: - completed: moved provider/config interfaces behind Hawk-owned transport adapters ### PR 3 -- continue reducing any remaining non-adapter direct `eyrie/client` imports in Hawk where it improves clarity +- completed: removed all lower-level Eyrie imports from Hawk production code and + replaced the compatibility allowlist with a zero-exception guard ### PR 4 - completed: added neutral review/verification result contracts and wired Hawk bridge/persistence edges diff --git a/external/eyrie b/external/eyrie index 22541ca2..2e5ec4e3 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit 22541ca285ff9091195b95ef5a52ba1c6e31d6f3 +Subproject commit 2e5ec4e3bb03705d5a09792009f113625258fc5a diff --git a/go.mod b/go.mod index b1b6c0fd..629a190b 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.3 - github.com/GrayCodeAI/eyrie v0.1.4 + github.com/GrayCodeAI/eyrie v0.2.1 github.com/GrayCodeAI/hawk-core-contracts v0.1.4 github.com/GrayCodeAI/inspect v0.1.4 github.com/GrayCodeAI/sight v0.1.4 diff --git a/go.sum b/go.sum index 2858e37f..9130a13b 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8 github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GrayCodeAI/eyrie v0.1.4 h1:Y1sEPcfvnmaSKbi4h0tVcV/CE7Bf+IMDwJ0PVQ9f5ik= -github.com/GrayCodeAI/eyrie v0.1.4/go.mod h1:8zw4jj5pvD9/tUqDhRaT4nnvl3Q8Zl2x4mJyEve7Obc= +github.com/GrayCodeAI/eyrie v0.2.1 h1:MtUkQV7hjFy7mobbjojjmgmN96Bo137pXfp2pcgJPug= +github.com/GrayCodeAI/eyrie v0.2.1/go.mod h1:8zw4jj5pvD9/tUqDhRaT4nnvl3Q8Zl2x4mJyEve7Obc= github.com/GrayCodeAI/hawk-core-contracts v0.1.4 h1:HRcuxvl5RMgqmF0Lt1YwHY84nGcUwXculXtnrE/8nLI= github.com/GrayCodeAI/hawk-core-contracts v0.1.4/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/inspect v0.1.4 h1:tQAcD9UuHkrqA20CZqdTcBgWKU/lhxxHBh8hlXY3FVw= diff --git a/internal/config/catalog_api.go b/internal/config/catalog_api.go index e35ebf0d..cac8f3fd 100644 --- a/internal/config/catalog_api.go +++ b/internal/config/catalog_api.go @@ -5,275 +5,293 @@ import ( "sort" "strings" - "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/runtime" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) -type GatewayStatus = runtime.GatewayStatus - -// CompiledCatalogV1 loads the eyrie catalog from cache or bootstrap wiring (no network). -func CompiledCatalogV1() *catalog.CompiledCatalog { - return compiledCatalogOrBootstrap() -} - -func compiledCatalogOrBootstrap() *catalog.CompiledCatalog { - if compiled, ok := cachedCompiledCatalog(); ok && compiled != nil { - return compiled - } - compiled, err := loadEyrieCatalogV1(context.Background(), false) - if err == nil && compiled != nil { - storeCompiledCatalog(compiled) - return compiled - } - bootstrap := catalog.BootstrapCatalog() - compiled, err = catalog.CompileCatalog(&bootstrap) - if err != nil { - return nil - } - storeCompiledCatalog(compiled) - return compiled +type GatewayStatus struct { + ID string + DisplayName string + HasStoredCredential bool + HasConfiguredDeployment bool + ModelCount int + Active bool + RegionLabel string + RegionRequired bool } -// AllCatalogProviders returns provider IDs from eyrie (providers + deployments, not hawk constants). func AllCatalogProviders() []string { - compiled := compiledCatalogOrBootstrap() - if compiled == nil { + engine, err := newEyrieEngine() + if err != nil { return nil } + providers, _ := engine.ModelProviders(context.Background()) seen := map[string]bool{} - var out []string - for _, id := range catalog.ProviderIDsFromCompiled(compiled) { - p := runtime.CatalogProviderID(id) - if p == "" || seen[p] { - continue + for _, provider := range providers { + if provider = eyrieengine.NormalizeProviderID(provider); provider != "" { + seen[provider] = true } - seen[p] = true - out = append(out, p) } - sort.Strings(out) - return out + for _, gateway := range engine.GatewayDefinitions() { + seen[gateway.ID] = true + } + providers = providers[:0] + for provider := range seen { + providers = append(providers, provider) + } + sort.Strings(providers) + return providers } -// AllSetupGateways returns gateway IDs where users paste API keys (eyrie registry only). -// Aggregator owner slugs from OpenRouter/CanopyWave catalogs (ai21, alibaba, …) are excluded. func AllSetupGateways() []string { - return runtime.SetupGateways() + engine, err := newEyrieEngine() + if err != nil { + return nil + } + gateways := engine.GatewayDefinitions() + out := make([]string, 0, len(gateways)) + for _, gateway := range gateways { + out = append(out, gateway.ID) + } + return out } -// SetupGatewayCredentialEnv returns the registry env var for a setup gateway (e.g. XIAOMI_MIMO_PAYG_API_KEY). func SetupGatewayCredentialEnv(providerID string) string { - return runtime.SetupGatewayCredentialEnv(providerID) + if gateway, ok := engineGateway(providerID); ok { + return gateway.CredentialEnv + } + return "" } -// IsSetupGateway reports whether id is a registered setup gateway. func IsSetupGateway(providerID string) bool { - return runtime.IsSetupGateway(providerID) + _, ok := engineGateway(providerID) + return ok } -func GatewayDisplayName(gatewayID string) string { - return runtime.GatewayDisplayName(gatewayID) -} - -// ActiveGateway returns the user's setup gateway (never an aggregator owner slug like moonshotai). -func ActiveGateway(ctx context.Context) string { - if ctx == nil { - ctx = context.Background() +func GatewayDisplayName(providerID string) string { + if gateway, ok := engineGateway(providerID); ok { + return gateway.DisplayName } - return runtime.ActiveGateway(ctx) + return providerID } -func GatewayStatuses(ctx context.Context, activeProvider, activeModel string) []GatewayStatus { - if ctx == nil { - ctx = context.Background() +func GatewaySupportsLiveDiscovery(providerID string) bool { + if gateway, ok := engineGateway(providerID); ok { + return gateway.SupportsLiveDiscovery } - ensureCredSnapshot(ctx) + return false +} - active := activeProvider - if active == "" && activeModel != "" { - active = GatewayForModel(activeModel) - } - if active == "" { - active = ActiveGateway(ctx) +func ActiveGateway(ctx context.Context) string { + engine, err := newEyrieEngine() + if err != nil { + return "" } - - uiCacheMu.RLock() - configured := credConfigured - uiCacheMu.RUnlock() - - compiled := compiledCatalogOrBootstrap() - gateways := runtime.SetupGateways() - statuses := make([]GatewayStatus, 0, len(gateways)) - - for _, providerID := range gateways { - count := 0 - if compiled != nil { - count = len(catalog.ModelEntriesForProvider(compiled, providerID)) + selection := engine.ActiveSelection(ctx) + providerID := eyrieengine.NormalizeProviderID(selection.Provider) + for _, gateway := range engine.GatewayDefinitions() { + if eyrieengine.NormalizeProviderID(gateway.ID) == providerID { + return gateway.ID } + } + return engine.GatewayForModel(ctx, selection.Model) +} - hasKey := false - if configured != nil { - hasKey = configured[providerID] +func GatewayStatuses(ctx context.Context, activeProvider, activeModel string) []GatewayStatus { + engine, err := newEyrieEngine() + if err != nil { + return nil + } + gateways := engine.Gateways(ctx) + out := make([]GatewayStatus, 0, len(gateways)) + for _, gateway := range gateways { + active := gateway.Active + if activeProvider != "" { + active = eyrieengine.NormalizeProviderID(activeProvider) == eyrieengine.NormalizeProviderID(gateway.ID) + } else if activeModel != "" { + active = engine.GatewayForModel(ctx, activeModel) == gateway.ID } - - statuses = append(statuses, GatewayStatus{ - ID: providerID, - DisplayName: runtime.GatewayDisplayName(providerID), - HasStoredCredential: hasKey, - HasConfiguredDeployment: hasKey, - ModelCount: count, - Active: providerID == active, - RegionLabel: runtime.GatewayRegionLabel(providerID), - RegionRequired: runtime.GatewayNeedsRegion(providerID), + out = append(out, GatewayStatus{ + ID: gateway.ID, DisplayName: gateway.DisplayName, + HasStoredCredential: gateway.CredentialConfigured, + HasConfiguredDeployment: gateway.DeploymentConfigured, + ModelCount: gateway.ModelCount, Active: active, + RegionLabel: gateway.RegionLabel, RegionRequired: gateway.RegionRequired, }) } - - return statuses + return out } -// GatewayForModel resolves the setup gateway for a model id. func GatewayForModel(modelID string) string { - return catalog.GatewayForModel(CompiledCatalogV1(), modelID) + engine, err := newEyrieEngine() + if err != nil { + return "" + } + return engine.GatewayForModel(context.Background(), modelID) } -// ShouldClearSelectionAfterCredentialRemove reports whether provider/model should reset. func ShouldClearSelectionAfterCredentialRemove(ctx context.Context, removedProvider string) bool { - return runtime.ShouldClearSelectionAfterCredentialRemove(ctx, removedProvider) + engine, err := newEyrieEngine() + if err != nil { + return true + } + selection := engine.ActiveSelection(ctx) + removedProvider = eyrieengine.NormalizeProviderID(removedProvider) + return !engine.EffectiveSelection(ctx, eyrieengine.SelectionOptions{}).HasConfiguredDeployment || + eyrieengine.NormalizeProviderID(selection.Provider) == removedProvider || + eyrieengine.NormalizeProviderID(engine.GatewayForModel(ctx, selection.Model)) == removedProvider } -// ClearActiveSelection removes persisted provider/model from provider.json. func ClearActiveSelection(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() + engine, err := newEyrieEngine() + if err != nil { + return err } - return runtime.ClearActiveSelection(ctx) + return engine.ClearSelection(ctx) } -// SyncSelectionWithCredentials clears stale provider/model when keys are missing. func SyncSelectionWithCredentials(ctx context.Context) { - if ctx == nil { - ctx = context.Background() + engine, err := newEyrieEngine() + if err != nil { + return + } + active := engine.ActiveSelection(ctx) + ready := map[string]bool{} + hasAny := false + for _, gateway := range engine.Gateways(ctx) { + providerID := eyrieengine.NormalizeProviderID(gateway.ID) + ready[providerID] = gateway.DeploymentConfigured + hasAny = hasAny || gateway.DeploymentConfigured + } + activeGateway := eyrieengine.NormalizeProviderID(active.Provider) + if activeGateway == "" && active.Model != "" { + activeGateway = eyrieengine.NormalizeProviderID(engine.GatewayForModel(ctx, active.Model)) + } + if !hasAny || (activeGateway != "" && !ready[activeGateway]) { + _ = engine.ClearSelection(ctx) } - runtime.SyncSelectionWithCredentials(ctx) } func DefaultModelForProvider(provider string) string { - return runtime.DefaultModelForProvider(context.Background(), provider) + engine, err := newEyrieEngine() + if err != nil { + return "" + } + return engine.DefaultModel(context.Background(), provider, "") +} + +func DefaultModelForProviderWithSettings(settings Settings, provider string) string { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return "" + } + return engine.DefaultModel(context.Background(), provider, "") } -// CachedModelCountForProvider returns model count from the on-disk catalog only (no network). func CachedModelCountForProvider(provider string) int { - return runtime.CachedModelCountForProvider(context.Background(), provider) + models, _ := ListEngineModels(context.Background(), provider, false) + return len(models) } func ModelIDsForProvider(provider string) ([]string, error) { - entries, err := FetchModelsForProvider(provider) + models, err := ListEngineModels(context.Background(), provider, false) if err != nil { return nil, err } - out := make([]string, 0, len(entries)) - for _, e := range entries { - if e.ID != "" { - out = append(out, e.ID) + out := make([]string, 0, len(models)) + for _, model := range models { + if model.ID != "" { + out = append(out, model.ID) } } return out, nil } -// CheapestModelForProvider picks the lowest input-priced model from eyrie's catalog. func CheapestModelForProvider(provider, fallback string) string { - entries, err := FetchModelsForProvider(provider) - if err != nil || len(entries) == 0 { + engine, err := newEyrieEngine() + if err != nil { return fallback } - cheapest := entries[0] - for _, e := range entries[1:] { - if e.InputPricePer1M > 0 && (cheapest.InputPricePer1M == 0 || e.InputPricePer1M < cheapest.InputPricePer1M) { - cheapest = e - } - } - if cheapest.ID != "" { - return cheapest.ID - } - return fallback + return engine.PreferredModel(context.Background(), provider, eyrieengine.ModelClassEconomical, fallback) } -// ProviderOfModel resolves catalog provider for a canonical model ID or alias. func ProviderOfModel(modelName string) string { - compiled := CompiledCatalogV1() - if compiled == nil { + engine, err := newEyrieEngine() + if err != nil { return "" } - if canonical, ok := compiled.CanonicalModelForAliasOrID(modelName); ok { - if model := compiled.ModelsByID[canonical]; model.ID != "" { - return runtime.CatalogProviderID(model.ProviderID) - } - } - return "" + return eyrieengine.NormalizeProviderID(engine.ProviderForModel(context.Background(), modelName)) } -// ExampleModelHints returns short example model aliases for user-facing error messages. -func ExampleModelHints() (anthropic, openai string) { - compiled := CompiledCatalogV1() - if compiled == nil { - return "claude-sonnet-4-6", "gpt-4o" - } - if _, ok := compiled.CanonicalModelForAliasOrID("claude-sonnet-4-6"); ok { - anthropic = "claude-sonnet-4-6" - } - if _, ok := compiled.CanonicalModelForAliasOrID("gpt-4o"); ok { - openai = "gpt-4o" - } - if anthropic == "" || openai == "" { - for _, id := range []string{"anthropic/claude-sonnet-4-6", "openai/gpt-4o"} { - if _, ok := compiled.ModelsByID[id]; !ok { - continue - } - if strings.HasPrefix(id, "anthropic/") && anthropic == "" { - anthropic = strings.TrimPrefix(id, "anthropic/") - } - if strings.HasPrefix(id, "openai/") && openai == "" { - openai = strings.TrimPrefix(id, "openai/") - } - } +func ProviderOfModelWithSettings(settings Settings, modelName string) string { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return "" } - if anthropic == "" || openai == "" { - return "claude-sonnet-4-6", "gpt-4o" + return eyrieengine.NormalizeProviderID(engine.ProviderForModel(context.Background(), modelName)) +} + +func ExampleModelHints() (string, string) { + engine, err := newEyrieEngine() + if err != nil { + return "an Anthropic model", "an OpenAI model" } - return anthropic, openai + return engine.DefaultModel(context.Background(), "anthropic", "an Anthropic model"), + engine.DefaultModel(context.Background(), "openai", "an OpenAI model") } -// AllCanonicalModelIDs returns sorted canonical model IDs from the eyrie catalog. func AllCanonicalModelIDs() []string { - compiled := compiledCatalogOrBootstrap() - if compiled == nil { + engine, err := newEyrieEngine() + if err != nil { return nil } - out := make([]string, 0, len(compiled.ModelsByID)) - for id := range compiled.ModelsByID { - out = append(out, id) + snapshot, err := engine.Catalog(context.Background()) + if err != nil { + return nil + } + out := make([]string, 0, len(snapshot.Models)) + for _, model := range snapshot.Models { + out = append(out, model.ID) } sort.Strings(out) return out } -// ProviderIDForDeployment returns the catalog provider id for a deployment (e.g. anthropic-direct → anthropic). func ProviderIDForDeployment(deploymentID string) string { - compiled := compiledCatalogOrBootstrap() - if compiled == nil { + engine, err := newEyrieEngine() + if err != nil { return "" } - dep, ok := compiled.DeploymentsByID[deploymentID] - if !ok { - return "" + for _, gateway := range engine.GatewayDefinitions() { + if gateway.DeploymentID == strings.TrimSpace(deploymentID) { + return gateway.ID + } } - return runtime.CatalogProviderID(dep.ProviderID) + return "" } -// PrimaryAPIKeyEnvForDeployment returns the env var name for a deployment's API key. func PrimaryAPIKeyEnvForDeployment(deploymentID string) string { - compiled := compiledCatalogOrBootstrap() - if compiled == nil { + engine, err := newEyrieEngine() + if err != nil { return "" } - return catalog.PrimaryAPIKeyEnvForDeployment(compiled, deploymentID) + for _, gateway := range engine.GatewayDefinitions() { + if gateway.DeploymentID == strings.TrimSpace(deploymentID) { + return gateway.CredentialEnv + } + } + return "" +} + +func engineGateway(providerID string) (eyrieengine.Gateway, bool) { + engine, err := newEyrieEngine() + if err != nil { + return eyrieengine.Gateway{}, false + } + providerID = eyrieengine.NormalizeProviderID(providerID) + for _, gateway := range engine.GatewayDefinitions() { + if eyrieengine.NormalizeProviderID(gateway.ID) == providerID { + return gateway, true + } + } + return eyrieengine.Gateway{}, false } diff --git a/internal/config/catalog_compat_test.go b/internal/config/catalog_compat_test.go new file mode 100644 index 00000000..a7909d1c --- /dev/null +++ b/internal/config/catalog_compat_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog" + eyrieconfig "github.com/GrayCodeAI/eyrie/config" +) + +// CompiledCatalogV1 is retained only for lower-level migration/security tests. +// Production Hawk code consumes engine DTOs exclusively. +func CompiledCatalogV1() *catalog.CompiledCatalog { + compiled, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{CachePath: catalog.DefaultCachePath()}) + if err == nil && compiled != nil { + return compiled + } + bootstrap := catalog.BootstrapCatalog() + compiled, _ = catalog.CompileCatalog(&bootstrap) + return compiled +} + +func deploymentHasSecrets(deployment eyrieconfig.DeploymentConfig) bool { + return strings.TrimSpace(deployment.APIKey) != "" || strings.TrimSpace(deployment.Token) != "" || + strings.TrimSpace(deployment.SecretAccessKey) != "" || strings.TrimSpace(deployment.AccessKeyID) != "" || + strings.TrimSpace(deployment.SessionToken) != "" +} diff --git a/internal/config/catalog_health.go b/internal/config/catalog_health.go index c93c80ef..834253ae 100644 --- a/internal/config/catalog_health.go +++ b/internal/config/catalog_health.go @@ -6,9 +6,6 @@ import ( "strings" "sync" "time" - - "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/hawk/internal/env" ) var ( @@ -36,7 +33,7 @@ type CatalogHealth struct { // CatalogHealthReport inspects ~/.eyrie/model_catalog.json (or EYRIE_MODEL_CATALOG_PATH). func CatalogHealthReport(ctx context.Context) CatalogHealth { - path := catalog.DefaultCachePath() + path := CatalogCachePathForDisplay() catalogHealthMu.Lock() if !catalogHealthAt.IsZero() && time.Since(catalogHealthAt) < catalogHealthCacheTTL && catalogHealthCache.CachePath == path { h := catalogHealthCache @@ -62,37 +59,19 @@ func InvalidateCatalogHealthCache() { } func catalogHealthReportUncached(ctx context.Context) CatalogHealth { - path := catalog.DefaultCachePath() - h := CatalogHealth{CachePath: path} - exists, mod, size, err := catalog.CacheInfo(path) + engine, err := newEyrieEngine() if err != nil { - h.Error = err.Error() - return h + return CatalogHealth{Error: err.Error()} } - h.Exists = exists - h.Modified = mod - h.SizeBytes = size - if !exists { - h.Error = "cache missing — hawk will discover automatically on start" - return h + status := engine.CatalogHealth(ctx) + h := CatalogHealth{ + CachePath: status.Path, Exists: status.Exists, Modified: status.ModifiedAt, + SizeBytes: status.Size, Models: status.Models, Deployments: status.Deployments, + Offerings: status.Offerings, Stale: status.Stale, StaleAfter: status.StaleAfter, + Source: status.Source, Error: status.Error, } - compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ - CachePath: path, - RequireCache: true, - }) - if err != nil { - h.Error = err.Error() - return h - } - h.Models = len(compiled.ModelsByID) - h.Deployments = len(compiled.DeploymentsByID) - h.Offerings = len(compiled.OfferingsByID) - if compiled.Catalog != nil && compiled.Catalog.Provenance != nil { - h.Source = compiled.Catalog.Provenance.Source - } - if compiled.Catalog != nil && !compiled.Catalog.StaleAfter.IsZero() { - h.StaleAfter = compiled.Catalog.StaleAfter - h.Stale = time.Now().UTC().After(compiled.Catalog.StaleAfter) + if !h.Exists && h.Error == "" { + h.Error = "cache missing — hawk will discover automatically on start" } return h } @@ -147,8 +126,9 @@ func EnsureCatalogAvailable(ctx context.Context) error { // CatalogCachePathForDisplay returns the path users should care about. func CatalogCachePathForDisplay() string { - if p := strings.TrimSpace(env.Getenv("EYRIE_MODEL_CATALOG_PATH")); p != "" { - return p + engine, err := newEyrieEngine() + if err == nil { + return engine.StatePaths().Catalog } - return catalog.DefaultCachePath() + return "" } diff --git a/internal/config/catalog_startup.go b/internal/config/catalog_startup.go index ad11e858..de6dab0b 100644 --- a/internal/config/catalog_startup.go +++ b/internal/config/catalog_startup.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/hawk/internal/env" ) @@ -162,18 +161,14 @@ func AutoRefreshCatalog(ctx context.Context, out io.Writer, verbose bool) error if err != nil { return err } - if result.Compiled != nil { - storeCompiledCatalog(result.Compiled) - } InvalidateConfigUICache() if out != nil { if verbose { - _, _ = fmt.Fprintln(out, strings.TrimSpace(result.DiscoverReport())) - } else if result.Compiled != nil { + _, _ = fmt.Fprintln(out, strings.TrimSpace(formatCatalogSnapshot(result))) + } else { _, _ = fmt.Fprintf( - out, "Catalog ready: %d models, %d deployments → %s\n", - len(result.Compiled.ModelsByID), - len(result.Compiled.DeploymentsByID), + out, "Catalog ready: %d models → %s\n", + len(result.Models), result.CachePath, ) } @@ -228,9 +223,9 @@ func DiscoverCatalogAfterSetup(ctx context.Context, out io.Writer) { func catalogRefreshFailureHint(ctx context.Context) string { if !HasConfiguredDeployment(ctx) { - return "No API keys in " + credentials.PlatformSecretStoreName() + ". Run /config to paste a key or set up Ollama." + return "No API keys in " + CredentialStoreName() + ". Run /config to paste a key or set up Ollama." } - return "Check network access and stored keys (" + credentials.PlatformSecretStoreName() + "). Run hawk preflight or /config." + return "Check network access and stored keys (" + CredentialStoreName() + "). Run hawk preflight or /config." } func autoRefreshCatalogEnabled() bool { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f1e6d52d..232082af 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -173,15 +173,16 @@ func TestLoadSettingsUsesUserConfigOnly(t *testing.T) { t.Setenv("HOME", home) configDir := filepath.Join(home, "config") t.Setenv("HAWK_CONFIG_DIR", configDir) + t.Setenv("EYRIE_CONFIG_DIR", filepath.Join(home, "eyrie")) if err := os.MkdirAll(configDir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(configDir, "settings.json"), []byte(`{"model":"global","allowedTools":["Read"]}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(configDir, "settings.json"), []byte(`{"model":"gpt-4o","allowedTools":["Read"]}`), 0o644); err != nil { t.Fatal(err) } settings := LoadSettings() - if got := ActiveModel(context.TODO()); got != "global" { + if got := ActiveModel(context.TODO()); got != "openai/gpt-4o" { t.Fatalf("expected global model in eyrie, got %q (settings.model=%q)", got, settings.Model) } if settings.Model != "" { @@ -199,8 +200,8 @@ func TestLoadSettingsUsesUserConfigOnly(t *testing.T) { } func TestSetGlobalSettingAndSettingValue(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - if err := SetGlobalSetting("model", "test-model"); err != nil { + testutil.IsolateStorage(t) + if err := SetGlobalSetting("model", "gpt-4o"); err != nil { t.Fatal(err) } if err := SetGlobalSetting("allowedTools", "Read, Write"); err != nil { @@ -215,13 +216,13 @@ func TestSetGlobalSettingAndSettingValue(t *testing.T) { } settings := LoadGlobalSettings() - if got := ActiveModel(context.TODO()); got != "test-model" { + if got := ActiveModel(context.TODO()); got != "openai/gpt-4o" { t.Fatalf("unexpected active model: %q (settings.model=%q)", got, settings.Model) } if settings.Model != "" { t.Fatalf("model must not be stored in settings.json, got %q", settings.Model) } - if got, ok := SettingValue(settings, "model"); !ok || got != "test-model" { + if got, ok := SettingValue(settings, "model"); !ok || got != "openai/gpt-4o" { t.Fatalf("unexpected model setting value: %q ok=%v", got, ok) } if got, ok := SettingValue(settings, "allowed_tools"); !ok || got != "Read, Write" { @@ -234,12 +235,42 @@ func TestSetGlobalSettingAndSettingValue(t *testing.T) { store := &credentials.MapStore{} credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) - _ = store.Set(context.Background(), credentials.AccountForEnv("OPENAI_API_KEY"), "sk-test") + _ = store.Set(context.Background(), credentials.AccountForEnv("OPENAI_API_KEY"), "sk-live-config-test-1234567890") if got, ok := SettingValue(settings, "apiKey.openai"); !ok || got != "set" { t.Fatalf("unexpected provider API key status: %q ok=%v", got, ok) } } +func TestLoadSettingsPreservesRejectedLegacySelection(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, "config") + t.Setenv("HOME", home) + t.Setenv("HAWK_CONFIG_DIR", configDir) + t.Setenv("EYRIE_CONFIG_DIR", filepath.Join(home, "eyrie")) + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(configDir, "settings.json") + if err := os.WriteFile(path, []byte(`{"model":"not-in-the-catalog","provider":"openai","allowedTools":["Read"]}`), 0o600); err != nil { + t.Fatal(err) + } + + settings := LoadSettings() + if settings.Model != "not-in-the-catalog" || settings.Provider != "openai" { + t.Fatalf("rejected legacy selection was erased: %#v", settings) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "not-in-the-catalog") { + t.Fatalf("rejected legacy selection was removed from disk: %s", data) + } + if got := ActiveModel(context.Background()); got != "" { + t.Fatalf("rejected legacy model reached Eyrie state: %q", got) + } +} + func TestLoadAgentsMD_VisibleFile(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("project instructions"), 0o644) diff --git a/internal/config/credentials_store.go b/internal/config/credentials_store.go index 81eaac50..058ad3ba 100644 --- a/internal/config/credentials_store.go +++ b/internal/config/credentials_store.go @@ -5,10 +5,7 @@ import ( "fmt" "strings" - eyriecfg "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/credentials" - "github.com/GrayCodeAI/eyrie/runtime" - "github.com/GrayCodeAI/eyrie/setup" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) // PersistAPIKey saves a provider API key via eyrie (OS secret store). @@ -18,30 +15,17 @@ func PersistAPIKey(ctx context.Context, envKey, secret string) error { if secret == "" || envKey == "" { return nil } - if err := eyriecfg.ValidateCredentialSecret(envKey, secret); err != nil { + engine, err := newEyrieEngine() + if err != nil { return err } - if err := runtime.SetCredential(ctx, envKey, secret); err != nil { + if err := engine.SaveCredentialEnv(ctx, envKey, secret); err != nil { return err } InvalidateConfigUICache() return nil } -// PrepareCredentialDiscovery prepares runtime credential discovery without reading legacy files. -func PrepareCredentialDiscovery(ctx context.Context) { - if ctx == nil { - ctx = context.Background() - } - runtime.PrepareCredentialDiscovery(ctx) -} - -// ModelOption is one hawk /config model row. -type ModelOption struct { - ID string - DisplayName string -} - // CredentialInference is one eyrie provider match for a pasted API key. type CredentialInference struct { ProviderID string @@ -71,7 +55,11 @@ type CredentialResolveResult struct { // ResolveCredential validates format and lists all providers from eyrie registry. func ResolveCredential(ctx context.Context, secret string) CredentialResolveResult { - res := runtime.ResolveCredential(ctx, secret) + engine, err := newEyrieEngine() + if err != nil { + return CredentialResolveResult{FormatError: err.Error()} + } + res := engine.ResolveCredential(ctx, secret) out := CredentialResolveResult{ FormatOK: res.FormatOK, FormatError: res.FormatError, @@ -84,7 +72,7 @@ func ResolveCredential(ctx context.Context, secret string) CredentialResolveResu DeploymentID: p.DeploymentID, EnvVar: p.EnvVar, DisplayName: p.DisplayName, - Inferred: p.Inferred, + Inferred: false, RequiresKey: p.RequiresKey, Rank: p.Rank, } @@ -104,10 +92,11 @@ func InferenceFromOption(opt CredentialProviderOption) CredentialInference { // SaveCredential validates, probes, and stores via eyrie keychain. func SaveCredential(ctx context.Context, inference CredentialInference, secret string) error { - if err := runtime.SaveCredential(ctx, runtime.CredentialInference{ - ProviderID: inference.ProviderID, DeploymentID: inference.DeploymentID, - EnvVar: inference.EnvVar, DisplayName: inference.DisplayName, - }, secret); err != nil { + engine, err := newEyrieEngine() + if err != nil { + return err + } + if _, err := engine.SaveCredential(ctx, inference.ProviderID, secret); err != nil { return err } InvalidateConfigUICache() @@ -116,7 +105,12 @@ func SaveCredential(ctx context.Context, inference CredentialInference, secret s // HasStoredCredentialForProvider reports whether the OS secret store has a key for this gateway. func HasStoredCredentialForProvider(ctx context.Context, providerID string) bool { - return runtime.HasStoredCredential(ctx, providerID) + engine, err := newEyrieEngine() + if err != nil { + return false + } + status, err := engine.CredentialStatus(ctx, providerID) + return err == nil && status.Configured } // ConfiguredCredentialProviders returns setup gateways with a stored API key. @@ -129,13 +123,13 @@ func FormatCredentialCLIStatus(ctx context.Context) string { if ctx == nil { ctx = context.Background() } - report := credentials.StorageReportFor(ctx) + report := CredentialStorageStatus(ctx) var b strings.Builder fmt.Fprintf(&b, "Credential storage: %s only\n", report.PlatformStore) - if report.KeychainWritable { + if report.Writable { b.WriteString(" Keychain: writable\n") } else { - fmt.Fprintf(&b, " Keychain: %s\n", report.KeychainDetail) + fmt.Fprintf(&b, " Keychain: %s\n", report.Detail) } providers := ConfiguredCredentialProviders() if len(providers) == 0 { @@ -152,28 +146,23 @@ func RemoveStoredCredential(ctx context.Context, target string) ([]string, error if target == "" { return nil, fmt.Errorf("provider or env var name required") } - envKeys := credentialEnvKeysForTarget(target) - if len(envKeys) == 0 { - return nil, fmt.Errorf("unknown provider %q", target) + engine, err := newEyrieEngine() + if err != nil { + return nil, err } - var removed []string - for _, envKey := range envKeys { - if !credentials.HasSecret(ctx, envKey) { - continue - } - if err := credentials.DeleteSecret(ctx, envKey); err != nil { - if len(removed) > 0 { - InvalidateConfigUICache() - } - return removed, err - } - removed = append(removed, envKey) + provider, ok := engineCredentialProvider(engine.CredentialProviders(ctx), target) + if !ok { + return nil, fmt.Errorf("unknown provider %q", target) } - if len(removed) == 0 { + status, err := engine.CredentialStatus(ctx, provider.ProviderID) + if err != nil || !status.Configured { return nil, fmt.Errorf("no stored credential for %q", target) } + if err := engine.RemoveCredential(ctx, provider.ProviderID); err != nil { + return nil, err + } InvalidateConfigUICache() - return removed, nil + return []string{provider.EnvVar}, nil } // MaskCredentialForProvider returns a partially masked API key for UI display. @@ -181,12 +170,12 @@ func MaskCredentialForProvider(ctx context.Context, provider string) string { if ctx == nil { ctx = context.Background() } - for _, envKey := range credentialEnvKeysForTarget(provider) { - secret := credentials.LookupSecret(ctx, envKey) - if secret == "" { - continue + engine, err := newEyrieEngine() + if err == nil { + status, statusErr := engine.CredentialStatus(ctx, provider) + if statusErr == nil && status.Masked != "" { + return status.Masked } - return maskCredentialSecret(secret) } return "••••••••" } @@ -206,11 +195,14 @@ func maskCredentialSecret(secret string) string { // CredentialInferenceForProvider returns save metadata for a gateway chosen in /config. func CredentialInferenceForProvider(providerID string) (CredentialInference, error) { - providerID = runtime.SetupGatewayID(providerID) - inf, err := runtime.InferenceForProvider(providerID) + engine, err := newEyrieEngine() if err != nil { return CredentialInference{}, err } + inf, ok := engineCredentialProvider(engine.CredentialProviders(context.Background()), providerID) + if !ok { + return CredentialInference{}, fmt.Errorf("unknown provider %q", providerID) + } return CredentialInference{ ProviderID: inf.ProviderID, DeploymentID: inf.DeploymentID, @@ -219,25 +211,9 @@ func CredentialInferenceForProvider(providerID string) (CredentialInference, err }, nil } -func credentialEnvKeysForTarget(target string) []string { - if strings.Contains(target, "_") && strings.ToUpper(target) == target { - return []string{strings.TrimSpace(target)} - } - return runtime.CredentialEnvKeys(target) -} - // LocalCredentialInference returns setup metadata for no-key providers (e.g. Ollama). func LocalCredentialInference(providerID string) (CredentialInference, error) { - inf, err := runtime.LocalCredentialInference(providerID) - if err != nil { - return CredentialInference{}, err - } - return CredentialInference{ - ProviderID: inf.ProviderID, - DeploymentID: inf.DeploymentID, - EnvVar: inf.EnvVar, - DisplayName: inf.DisplayName, - }, nil + return CredentialInferenceForProvider(providerID) } // FormatConfigProviderError maps eyrie setup errors to user-facing /config hints. @@ -245,15 +221,12 @@ func FormatConfigProviderError(providerID string, err error) string { if err == nil { return "" } - if formatted := runtime.FormatSetupError(providerID, err); formatted != nil { - return formatted.Error() - } - return err.Error() + return eyrieengine.FormatSetupError(providerID, err) } // InferCredentialsFromAPIKey is deprecated; select gateway first, then paste the key. func InferCredentialsFromAPIKey(ctx context.Context, secret string) []CredentialInference { - in := runtime.InferCredentialsFromAPIKey(ctx, secret) + in := ResolveCredential(ctx, secret).Providers out := make([]CredentialInference, len(in)) for i, c := range in { out[i] = CredentialInference{ @@ -266,23 +239,12 @@ func InferCredentialsFromAPIKey(ctx context.Context, secret string) []Credential return out } -// OptionsFromSetupUI builds picker rows; providerFilter limits to one provider. -func OptionsFromSetupUI(ui *setup.SetupUI, providerFilter string) []ModelOption { - if ui == nil { - return nil - } - providerFilter = strings.TrimSpace(providerFilter) - var out []ModelOption - for _, p := range ui.Providers { - if providerFilter != "" && p.ID != providerFilter { - continue - } - for _, m := range p.Models { - out = append(out, ModelOption{ - ID: m.CanonicalID, - DisplayName: m.DisplayName, - }) +func engineCredentialProvider(providers []eyrieengine.CredentialProvider, target string) (eyrieengine.CredentialProvider, bool) { + target = strings.TrimSpace(target) + for _, provider := range providers { + if strings.EqualFold(provider.ProviderID, target) || strings.EqualFold(provider.EnvVar, target) { + return provider, true } } - return out + return eyrieengine.CredentialProvider{}, false } diff --git a/internal/config/deployment.go b/internal/config/deployment.go index 04ed40a9..2767002b 100644 --- a/internal/config/deployment.go +++ b/internal/config/deployment.go @@ -1,12 +1,7 @@ package config -import ( - "context" - - "github.com/GrayCodeAI/eyrie/runtime" -) - // DeploymentRoutingEnabled delegates deployment-routing policy ownership to Eyrie runtime. func DeploymentRoutingEnabled(s Settings) bool { - return runtime.DeploymentRoutingEnabled(context.Background(), s.DeploymentRouting) + engine, err := newEyrieEngine() + return err == nil && engine.DeploymentRoutingEnabled(s.DeploymentRouting) } diff --git a/internal/config/deployment_status.go b/internal/config/deployment_status.go index 23d9c7e6..620ae7bb 100644 --- a/internal/config/deployment_status.go +++ b/internal/config/deployment_status.go @@ -3,25 +3,43 @@ package config import ( "context" "strings" - - "github.com/GrayCodeAI/eyrie/runtime" ) // ResolveCanonicalModel maps aliases and native IDs to catalog canonical model IDs. func ResolveCanonicalModel(model string) string { - return runtime.ResolveCanonicalModel(context.Background(), strings.TrimSpace(model)) + return CanonicalModelID(context.Background(), strings.TrimSpace(model)) } // DeploymentStatusReport returns hawk deployment routing diagnostics. func DeploymentStatusReport(ctx context.Context, activeModel string) (string, error) { - report, err := runtime.DeploymentStatus(ctx, activeModel) + engine, err := newEyrieEngine() if err != nil { return "", err } - return runtime.FormatDeploymentStatus(report), nil + return engine.DeploymentStatus(ctx, activeModel) +} + +func DeploymentStatusReportWithSettings(ctx context.Context, settings Settings, activeModel string) (string, error) { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return "", err + } + return engine.DeploymentStatus(ctx, activeModel) } // RoutingPreviewJSON returns effective routing for a model (eyrie routing JSON preview). func RoutingPreviewJSON(ctx context.Context, model string) (string, error) { - return runtime.RoutingPreview(ctx, model) + engine, err := newEyrieEngine() + if err != nil { + return "", err + } + return engine.RoutingPreview(ctx, model) +} + +func RoutingPreviewJSONWithSettings(ctx context.Context, settings Settings, model string) (string, error) { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return "", err + } + return engine.RoutingPreview(ctx, model) } diff --git a/internal/config/developer_path.go b/internal/config/developer_path.go index 0c54a086..0a5f4a83 100644 --- a/internal/config/developer_path.go +++ b/internal/config/developer_path.go @@ -2,15 +2,12 @@ package config import ( "context" - "encoding/json" "fmt" "os" "path/filepath" "strings" - eyriecfg "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/credentials" - "github.com/GrayCodeAI/eyrie/runtime" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" "github.com/GrayCodeAI/hawk/internal/home" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -53,8 +50,6 @@ func EvaluateDeveloperPath(ctx context.Context) DeveloperPathReport { if ctx == nil { ctx = context.Background() } - PrepareCredentialDiscovery(ctx) - var checks []PathCheck setup := EvaluateSetup(ctx) @@ -108,16 +103,17 @@ func EvaluateDeveloperPath(ctx context.Context) DeveloperPathReport { }) } - if ok, detail := credentials.KeychainWriteAvailable(ctx); ok { + storageStatus := CredentialStorageStatus(ctx) + if storageStatus.Writable { checks = append(checks, PathCheck{ Section: "Security", Name: "keychain", Status: PathPass, - Detail: credentials.PlatformSecretStoreName() + " writable", + Detail: CredentialStoreName() + " writable", Blocking: true, }) } else { checks = append(checks, PathCheck{ Section: "Security", Name: "keychain", Status: PathWarn, - Detail: detail, + Detail: storageStatus.Detail, FixHint: "Unlock keychain or enable secret service (Linux)", Blocking: true, }) @@ -153,7 +149,7 @@ func EvaluateDeveloperPath(ctx context.Context) DeveloperPathReport { } hawkDir := home.Dir() - provPath := eyriecfg.GetProviderConfigPath() + provPath := ProviderStateSecurityStatus().Path if provPath == "" { provPath = filepath.Join(hawkDir, ".hawk", "provider.json") } @@ -184,7 +180,7 @@ func EvaluateDeveloperPath(ctx context.Context) DeveloperPathReport { }) } - pre := runtime.Preflight(ctx) + pre := EnginePreflightReport(ctx) if pre.Ready { checks = append(checks, PathCheck{ Section: "Ecosystem", Name: "eyrie", Status: PathPass, @@ -194,7 +190,7 @@ func EvaluateDeveloperPath(ctx context.Context) DeveloperPathReport { } else { status := PathWarn for _, c := range pre.Checks { - if c.Status == runtime.PreflightFail { + if c.Status == eyrieengine.CheckFail { status = PathFail break } @@ -312,30 +308,11 @@ func pathStatusGlyph(s PathCheckStatus) string { } func providerJSONHasSecretsOnDisk() (bool, string) { - path := eyriecfg.GetProviderConfigPath() - data, err := os.ReadFile(path) // #nosec G304 -- path is eyrie's fixed provider config path, not external input - if err != nil { - if os.IsNotExist(err) { - return false, "" - } - return false, "" - } - text := string(data) - for _, needle := range []string{`"api_key"`, `"secret_access_key"`, `"session_token"`} { - if strings.Contains(text, needle+`": "`) && !strings.Contains(text, needle+`": ""`) { - return true, "provider.json contains " + needle + " field with value" - } - } - var cfg eyriecfg.ProviderConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return false, "" - } - for id, dep := range cfg.Deployments { - if deploymentHasSecrets(dep) { - return true, "deployment " + id + " has secret fields on disk" - } + status := ProviderStateSecurityStatus() + if status.Error != "" { + return true, status.Error } - return false, "" + return status.HasSecrets, status.Detail } func legacyCredentialFilesPresent() (bool, []string) { diff --git a/internal/config/ecosystem_report.go b/internal/config/ecosystem_report.go index 11d6503e..6f05057c 100644 --- a/internal/config/ecosystem_report.go +++ b/internal/config/ecosystem_report.go @@ -5,8 +5,6 @@ import ( "fmt" "strings" - "github.com/GrayCodeAI/eyrie/runtime" - "github.com/GrayCodeAI/eyrie/setup" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/tok" ) @@ -24,16 +22,16 @@ func FormatEcosystemPanel(ctx context.Context, provider, model string) string { } else { eyrieLine += "catalog missing (run hawk models refresh)" } - pre := runtime.Preflight(ctx) + pre := EnginePreflightReport(ctx) if pre.Ready { - eyrieLine += " · ready to chat" + eyrieLine += " · locally ready" } else { eyrieLine += " · setup incomplete" } if strings.TrimSpace(provider) != "" && provider != "auto" { eyrieLine += fmt.Sprintf(" · provider %s", provider) } - if dep, err := setup.DeploymentStatus(ctx, model); err == nil { + if dep, err := EngineDeploymentSummary(ctx, model); err == nil { if dep.RoutingStages > 0 { eyrieLine += fmt.Sprintf(" · routing %s (%d stages)", dep.RoutingSource, dep.RoutingStages) } else { diff --git a/internal/config/envmanager.go b/internal/config/envmanager.go index 089e53f4..ce3290a7 100644 --- a/internal/config/envmanager.go +++ b/internal/config/envmanager.go @@ -11,7 +11,6 @@ import ( "strings" "sync" - "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/hawk/internal/env" ) @@ -349,7 +348,7 @@ func (em *EnvManager) Validate() []string { ctx := context.Background() for _, key := range recommended { if _, ok := em.Vars[key]; !ok { - if !credentials.HasSecret(ctx, key) { + if !HasCredentialEnv(ctx, key) { warnings = append(warnings, fmt.Sprintf("WARNING: recommended credential %q is not configured — run /config", key)) } } diff --git a/internal/config/eyrie_apply.go b/internal/config/eyrie_apply.go index 44debcdb..cb2b1819 100644 --- a/internal/config/eyrie_apply.go +++ b/internal/config/eyrie_apply.go @@ -2,45 +2,78 @@ package config import ( "context" + "fmt" "time" - eyriecfg "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/runtime" - "github.com/GrayCodeAI/eyrie/setup" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) -// ApplyEyrieCredentialsForProvider refreshes live models for one provider after /config saves a key. -func ApplyEyrieCredentialsForProvider(ctx context.Context, providerID string) (*setup.ApplyCredentialsResult, error) { +// ApplyCredentialsResult is Hawk's UI-safe view of an Eyrie catalog/routing +// application. It intentionally excludes Eyrie setup/config implementation +// types from the product boundary. +type ApplyCredentialsResult struct { + Catalog eyrieengine.CatalogSnapshot +} + +// ApplyEyrieCredentialsForProvider refreshes live models and writes sanitized +// deployment routing after /config saves a key. +func ApplyEyrieCredentialsForProvider(ctx context.Context, providerID string) (*ApplyCredentialsResult, error) { ctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() - result, err := runtime.ApplyCredentialsForProvider(ctx, providerID) + engine, err := newEyrieEngine() + if err != nil { + return nil, err + } + snapshot, err := engine.ApplyCredentials(ctx, providerID) if err != nil { return nil, err } _ = SaveProjectOrGlobalDeploymentRouting(true) - return result, nil + return &ApplyCredentialsResult{Catalog: snapshot}, nil } -// ApplyEyrieCredentials discovers the catalog and writes provider.json (routing only on disk). -func ApplyEyrieCredentials(ctx context.Context) (*setup.ApplyCredentialsResult, error) { +// ApplyEyrieCredentials refreshes all configured providers and writes +// sanitized deployment routing. +func ApplyEyrieCredentials(ctx context.Context) (*ApplyCredentialsResult, error) { ctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() - PrepareCredentialDiscovery(ctx) - result, err := setup.ApplyCredentials(ctx, eyriecfg.DiscoveryCredentials(ctx)) + engine, err := newEyrieEngine() + if err != nil { + return nil, err + } + snapshot, err := engine.ApplyCredentials(ctx, "") if err != nil { return nil, err } _ = SaveProjectOrGlobalDeploymentRouting(true) - return result, nil + return &ApplyCredentialsResult{Catalog: snapshot}, nil } -// RefreshGatewayCatalog fetches live models for one gateway and updates the cache. +// RefreshGatewayCatalog refreshes catalog state through the facade. func RefreshGatewayCatalog(ctx context.Context, providerID string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() - return runtime.RefreshGatewayCatalog(ctx, providerID) + engine, err := newEyrieEngine() + if err != nil { + return "", err + } + snapshot, err := engine.RefreshCatalog(ctx, providerID) + if err != nil { + return "", err + } + return formatCatalogSnapshot(snapshot), nil } -func FormatApplyCredentialsSummary(result *setup.ApplyCredentialsResult) string { - return runtime.FormatApplyCredentialsSummary(result) +func FormatApplyCredentialsSummary(result *ApplyCredentialsResult) string { + if result == nil { + return "" + } + return formatCatalogSnapshot(result.Catalog) +} + +func formatCatalogSnapshot(snapshot eyrieengine.CatalogSnapshot) string { + if snapshot.CachePath == "" { + return fmt.Sprintf("Catalog ready: %d models", len(snapshot.Models)) + } + return fmt.Sprintf("Catalog ready: %d models → %s", len(snapshot.Models), snapshot.CachePath) } diff --git a/internal/config/eyrie_engine.go b/internal/config/eyrie_engine.go new file mode 100644 index 00000000..02259682 --- /dev/null +++ b/internal/config/eyrie_engine.go @@ -0,0 +1,186 @@ +package config + +import ( + "context" + + eyrieengine "github.com/GrayCodeAI/eyrie/engine" +) + +type ( + EngineModel = eyrieengine.Model + EnginePreflight = eyrieengine.PreflightReport + EnginePreflightOptions = eyrieengine.PreflightOptions +) + +func CredentialStoreName() string { return eyrieengine.SecretStoreName() } + +func CredentialStorageStatus(ctx context.Context) eyrieengine.CredentialStorageReport { + return eyrieengine.CredentialStorage(ctx) +} + +func MigrateLegacyCredentials(ctx context.Context) (int, error) { + return eyrieengine.MigrateLegacyCredentials(ctx) +} + +func EnginePreflightReport(ctx context.Context) EnginePreflight { + return EnginePreflightReportWithOptions(ctx, EnginePreflightOptions{}) +} + +func EnginePreflightReportWithOptions(ctx context.Context, opts EnginePreflightOptions) EnginePreflight { + engine, err := newEyrieEngine() + if err != nil { + return EnginePreflight{Checks: []eyrieengine.PreflightCheck{{ + Name: "engine", Status: eyrieengine.CheckFail, Detail: err.Error(), + }}} + } + return engine.PreflightWithOptions(ctx, opts) +} + +// EnginePreflightReportWithSettings runs preflight against one invocation's +// effective settings. Custom gateways never escape into package or process +// state, so concurrent commands can safely use different settings files. +func EnginePreflightReportWithSettings(ctx context.Context, settings Settings, opts EnginePreflightOptions) EnginePreflight { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return EnginePreflight{Checks: []eyrieengine.PreflightCheck{{ + Name: "engine", Status: eyrieengine.CheckFail, Detail: err.Error(), + }}} + } + return engine.PreflightWithOptions(ctx, opts) +} + +func FormatEnginePreflight(report EnginePreflight) string { + return eyrieengine.FormatPreflight(report) +} + +func EngineGatewayRegion(providerID string) (string, bool) { + engine, err := newEyrieEngine() + if err != nil { + return "", false + } + return engine.GatewayRegion(providerID) +} + +func SetEngineGatewayRegion(ctx context.Context, providerID, region string) error { + engine, err := newEyrieEngine() + if err != nil { + return err + } + return engine.SetGatewayRegion(ctx, providerID, region) +} + +func CanonicalModelID(ctx context.Context, modelID string) string { + engine, err := newEyrieEngine() + if err != nil { + return modelID + } + return engine.CanonicalModel(ctx, modelID) +} + +func HasCredentialEnv(ctx context.Context, envVar string) bool { + engine, err := newEyrieEngine() + return err == nil && engine.HasCredentialEnv(ctx, envVar) +} + +func CredentialGuidance(providerID, secret string) string { + return eyrieengine.CredentialGuidance(providerID, secret) +} + +func ProviderStateSecurityStatus() eyrieengine.ProviderStateSecurity { + engine, err := newEyrieEngine() + if err != nil { + return eyrieengine.ProviderStateSecurity{Error: err.Error(), Detail: "Eyrie engine initialization failed"} + } + return engine.ProviderStateSecurityStatus() +} + +func MigrateEngineProviderSecrets() error { + engine, err := newEyrieEngine() + if err != nil { + return err + } + return engine.MigrateProviderSecrets() +} + +func EngineDeploymentSummary(ctx context.Context, model string) (eyrieengine.DeploymentSummary, error) { + engine, err := newEyrieEngine() + if err != nil { + return eyrieengine.DeploymentSummary{}, err + } + return engine.DeploymentSummary(ctx, model) +} + +// newEyrieEngine is Hawk's default composition root for Eyrie's stable host +// facade. Command paths that support --settings must use +// NewEyrieEngineForSettings instead of relying on this global-settings default. +func newEyrieEngine() (*eyrieengine.Engine, error) { + return newEyrieEngineForCustomProviders(LoadGlobalSettings().CustomProviders) +} + +// ListEngineModels returns model-picker rows through Eyrie's stable facade. +func ListEngineModels(ctx context.Context, providerID string, refresh bool) ([]EngineModel, error) { + engine, err := newEyrieEngine() + if err != nil { + return nil, err + } + return engine.ListModels(ctx, providerID, refresh) +} + +func ListLiveEngineModels(ctx context.Context, providerID string) ([]EngineModel, error) { + engine, err := newEyrieEngine() + if err != nil { + return nil, err + } + return engine.ListLiveModels(ctx, providerID) +} + +func ListEngineModelsWithSettings(ctx context.Context, settings Settings, providerID string, refresh bool) ([]EngineModel, error) { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return nil, err + } + return engine.ListModels(ctx, providerID, refresh) +} + +func ListLiveEngineModelsWithSettings(ctx context.Context, settings Settings, providerID string) ([]EngineModel, error) { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return nil, err + } + return engine.ListLiveModels(ctx, providerID) +} + +func ListPublicEngineModels(ctx context.Context, providerID string) ([]EngineModel, error) { + engine, err := newEyrieEngine() + if err != nil { + return nil, err + } + return engine.ListPublicModels(ctx, providerID) +} + +func NewEyrieEngine() (*eyrieengine.Engine, error) { return newEyrieEngine() } + +// NewEyrieEngineForSettings composes a fresh Eyrie engine for exactly one +// effective Hawk settings snapshot. It performs no package-global registration +// and does not mutate provider environment variables. +func NewEyrieEngineForSettings(settings Settings) (*eyrieengine.Engine, error) { + return newEyrieEngineForCustomProviders(settings.CustomProviders) +} + +func newEyrieEngineForCustomProviders(providers []CustomProviderConfig) (*eyrieengine.Engine, error) { + return eyrieengine.New(eyrieengine.Options{CustomGateways: customGatewaysFromSettings(providers)}) +} + +func customGatewaysFromSettings(providers []CustomProviderConfig) []eyrieengine.CustomGateway { + gateways := make([]eyrieengine.CustomGateway, 0, len(providers)) + for _, provider := range providers { + if provider.Name == "" && provider.BaseURL == "" { + continue + } + gateways = append(gateways, eyrieengine.CustomGateway{ + ID: provider.Name, BaseURL: provider.BaseURL, + CredentialEnv: provider.APIKeyEnv, DefaultModel: provider.Model, + }) + } + return gateways +} diff --git a/internal/config/eyrie_engine_test.go b/internal/config/eyrie_engine_test.go new file mode 100644 index 00000000..5d564234 --- /dev/null +++ b/internal/config/eyrie_engine_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "context" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestNewEyrieEngineForSettingsIsInvocationScoped(t *testing.T) { + previousStore := credentials.DefaultStore() + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(previousStore) }) + + settings := Settings{CustomProviders: []CustomProviderConfig{{ + Name: "private-gateway", BaseURL: "https://private.example.test/v1", + APIKeyEnv: "PRIVATE_GATEWAY_API_KEY", Model: "private/model-v1", + }}} + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + var credentialEnv string + for _, gateway := range engine.GatewayDefinitions() { + if gateway.ID == "private_gateway" { + credentialEnv = gateway.CredentialEnv + } + } + if credentialEnv != "PRIVATE_GATEWAY_API_KEY" { + got := credentialEnv + t.Fatalf("custom credential env = %q", got) + } + models, err := ListEngineModelsWithSettings(ctx, settings, "private-gateway", false) + if err != nil || len(models) != 1 || models[0].ID != "private/model-v1" || models[0].GatewayID != "private_gateway" { + t.Fatalf("custom model surface = %+v, err=%v", models, err) + } + if setErr := store.Set(ctx, credentials.AccountForEnv("PRIVATE_GATEWAY_API_KEY"), "private-live-secret-1234567890"); setErr != nil { + t.Fatal(setErr) + } + status, err := engine.CredentialStatus(ctx, "private-gateway") + if err != nil || !status.Configured { + t.Fatalf("custom credential surface = %+v, err=%v", status, err) + } + + if _, collisionErr := NewEyrieEngineForSettings(Settings{CustomProviders: []CustomProviderConfig{{ + Name: "openai", BaseURL: "https://collision.example.test/v1", + }}}); collisionErr == nil { + t.Fatal("built-in custom gateway collision was accepted") + } + models, err = engine.ListModels(ctx, "private-gateway", false) + if err != nil || len(models) != 1 { + t.Fatalf("independent invalid construction changed existing engine: %+v, err=%v", models, err) + } + other, err := NewEyrieEngineForSettings(Settings{}) + if err != nil { + t.Fatal(err) + } + for _, gateway := range other.GatewayDefinitions() { + if gateway.ID == "private_gateway" { + t.Fatal("custom gateway leaked into another settings-scoped engine") + } + } +} diff --git a/internal/config/eyrie_selection.go b/internal/config/eyrie_selection.go index 420dbb89..4cc59a24 100644 --- a/internal/config/eyrie_selection.go +++ b/internal/config/eyrie_selection.go @@ -4,15 +4,42 @@ import ( "context" "strings" - "github.com/GrayCodeAI/eyrie/runtime" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) +type ( + Selection = eyrieengine.Selection + SelectionOptions = eyrieengine.SelectionOptions +) + +// EffectiveSelection resolves persisted selection and optional host overrides +// through Eyrie's host-neutral engine contract. +func EffectiveSelection(ctx context.Context, opts SelectionOptions) Selection { + engine, err := newEyrieEngine() + if err != nil { + return Selection{} + } + return engine.EffectiveSelection(ctx, opts) +} + +func EffectiveSelectionWithSettings(ctx context.Context, settings Settings, opts SelectionOptions) Selection { + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return Selection{} + } + return engine.EffectiveSelection(ctx, opts) +} + // ActiveModel returns the selected model from eyrie provider.json (not hawk settings). func ActiveModel(ctx context.Context) string { if ctx == nil { ctx = context.Background() } - return runtime.ActiveModel(ctx) + engine, err := newEyrieEngine() + if err != nil { + return "" + } + return engine.ActiveSelection(ctx).Model } // ActiveProvider returns the selected provider from eyrie provider.json. @@ -20,12 +47,16 @@ func ActiveProvider(ctx context.Context) string { if ctx == nil { ctx = context.Background() } - return runtime.ActiveProviderID(runtime.ActiveProvider(ctx)) + engine, err := newEyrieEngine() + if err != nil { + return "" + } + return engine.ActiveSelection(ctx).Provider } // ActiveProviderID canonicalizes a host-facing provider/gateway id through Eyrie runtime. func ActiveProviderID(provider string) string { - return runtime.ActiveProviderID(provider) + return eyrieengine.NormalizeProviderID(provider) } // SetActiveModel persists model selection to eyrie provider.json. @@ -33,7 +64,11 @@ func SetActiveModel(ctx context.Context, modelID string) error { if ctx == nil { ctx = context.Background() } - return runtime.SetActiveModel(ctx, modelID) + engine, err := newEyrieEngine() + if err != nil { + return err + } + return engine.SetActiveModel(ctx, modelID) } // SetActiveProvider persists provider selection to eyrie provider.json. @@ -41,7 +76,25 @@ func SetActiveProvider(ctx context.Context, provider string) error { if ctx == nil { ctx = context.Background() } - return runtime.SetActiveProvider(ctx, runtime.ActiveProviderID(provider)) + engine, err := newEyrieEngine() + if err != nil { + return err + } + return engine.SetActiveProvider(ctx, provider) +} + +// SetActiveSelection validates and persists a provider/model pair atomically. +// Use this for migrations and other flows where persisting only one half would +// leave provider state inconsistent. +func SetActiveSelection(ctx context.Context, provider, modelID string) error { + if ctx == nil { + ctx = context.Background() + } + engine, err := newEyrieEngine() + if err != nil { + return err + } + return engine.SetSelection(ctx, provider, modelID) } // migrateLegacyModelProvider moves model/provider from ~/.hawk/settings.json into eyrie once. @@ -50,20 +103,39 @@ func migrateLegacyModelProvider(s *Settings) { return } ctx := context.Background() + legacyModel := strings.TrimSpace(s.Model) + legacyProvider := strings.TrimSpace(s.Provider) + activeModel := strings.TrimSpace(ActiveModel(ctx)) + activeProvider := strings.TrimSpace(ActiveProvider(ctx)) changed := false - if m := strings.TrimSpace(s.Model); m != "" { - if strings.TrimSpace(ActiveModel(ctx)) == "" { - _ = SetActiveModel(ctx, m) + + // Existing Eyrie state is authoritative. Otherwise migrate a legacy pair + // in one validated write so a rejected model cannot strand only the + // provider in the destination or silently erase the user's source value. + if activeModel != "" { + if legacyModel != "" { + s.Model = "" + changed = true } - s.Model = "" - changed = true - } - if p := strings.TrimSpace(s.Provider); p != "" { - if strings.TrimSpace(ActiveProvider(ctx)) == "" { - _ = SetActiveProvider(ctx, p) + if legacyProvider != "" { + s.Provider = "" + changed = true + } + } else if legacyModel != "" { + provider := activeProvider + if provider == "" { + provider = legacyProvider + } + if err := SetActiveSelection(ctx, provider, legacyModel); err == nil { + s.Model = "" + s.Provider = "" + changed = true + } + } else if legacyProvider != "" { + if activeProvider != "" || SetActiveProvider(ctx, legacyProvider) == nil { + s.Provider = "" + changed = true } - s.Provider = "" - changed = true } if changed { _ = SaveGlobal(*s) diff --git a/internal/config/migrate_provider_secrets.go b/internal/config/migrate_provider_secrets.go index 50e8b978..88d1108b 100644 --- a/internal/config/migrate_provider_secrets.go +++ b/internal/config/migrate_provider_secrets.go @@ -1,61 +1,6 @@ package config -import ( - "encoding/json" - "os" - "strings" - - eyriecfg "github.com/GrayCodeAI/eyrie/config" -) - // MigrateProviderSecrets strips api keys from on-disk provider.json (one-time hygiene). func MigrateProviderSecrets() error { - path := eyriecfg.GetProviderConfigPath() - marker := path + ".secrets-migrated" - backup := path + ".pre-secret-migrate.bak" - if _, err := os.Stat(marker); err == nil { - // Migration already done: remove any leftover plaintext backup from - // earlier versions that kept it around. - _ = os.Remove(backup) - return nil - } - data, err := os.ReadFile(path) // #nosec G304 -- path is eyrie's fixed provider config path, not external input - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - var cfg eyriecfg.ProviderConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return err - } - changed := false - for id, dep := range cfg.Deployments { - if deploymentHasSecrets(dep) { - changed = true - } - cfg.Deployments[id] = eyriecfg.SanitizeDeploymentConfigForDisk(dep) - } - if !changed { - _ = os.WriteFile(marker, []byte("ok\n"), 0o600) - return nil - } - // Keep a backup only while the rewrite is in flight; it holds plaintext - // secrets, so it must not outlive a successful migration. - _ = os.WriteFile(backup, data, 0o600) - if err := eyriecfg.SaveProviderConfig(&cfg, path); err != nil { - return err - } - _ = os.WriteFile(marker, []byte("ok\n"), 0o600) - _ = os.Remove(backup) - return nil -} - -func deploymentHasSecrets(dep eyriecfg.DeploymentConfig) bool { - return strings.TrimSpace(dep.APIKey) != "" || - strings.TrimSpace(dep.Token) != "" || - strings.TrimSpace(dep.SecretAccessKey) != "" || - strings.TrimSpace(dep.AccessKeyID) != "" || - strings.TrimSpace(dep.SessionToken) != "" + return MigrateEngineProviderSecrets() } diff --git a/internal/config/milestone_verify_test.go b/internal/config/milestone_verify_test.go index 0a6e23ee..b9d7cebf 100644 --- a/internal/config/milestone_verify_test.go +++ b/internal/config/milestone_verify_test.go @@ -23,6 +23,7 @@ func isolateMilestoneTest(t *testing.T) string { } t.Setenv("HOME", home) t.Setenv("HAWK_CONFIG_DIR", hawkDir) + t.Setenv("EYRIE_CONFIG_DIR", hawkDir) return hawkDir } @@ -43,11 +44,15 @@ func TestVerify_ProviderJSONOnDiskHasNoSecrets(t *testing.T) { func TestVerify_MigrateProviderSecretsStripsDisk(t *testing.T) { hawkDir := isolateMilestoneTest(t) + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) path := filepath.Join(hawkDir, "provider.json") secret := "sk-ant-migrate-verify-key-1234567890" raw := `{ "version": "1", "config_version": 2, + "openai_api_key": "` + secret + `-top-level", "deployments": { "anthropic-direct": { "api_key": "` + secret + `" @@ -60,13 +65,18 @@ func TestVerify_MigrateProviderSecretsStripsDisk(t *testing.T) { if err := MigrateProviderSecrets(); err != nil { t.Fatal(err) } + for _, envKey := range []string{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"} { + if value, err := store.Get(context.Background(), credentials.AccountForEnv(envKey)); err != nil || !strings.Contains(value, secret) { + t.Fatalf("legacy %s was not imported before sanitizing: value=%q err=%v", envKey, value, err) + } + } assertProviderJSONFileHasNoSecrets(t, path) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if strings.Contains(string(data), secret) { - t.Fatal("provider.json still contains api key after migrate") + t.Fatal("provider.json still contains a legacy or deployment API key after migrate") } } diff --git a/internal/config/provider_filter.go b/internal/config/provider_filter.go index ec59f668..183dd965 100644 --- a/internal/config/provider_filter.go +++ b/internal/config/provider_filter.go @@ -2,8 +2,6 @@ package config import ( "context" - - "github.com/GrayCodeAI/eyrie/runtime" ) // DefaultModelProviderFilter picks which eyrie provider to list models for when the UI @@ -12,5 +10,9 @@ func DefaultModelProviderFilter(ctx context.Context) string { if p := ActiveGateway(ctx); p != "" { return p } - return runtime.DefaultModelProviderFilter(ctx) + engine, err := newEyrieEngine() + if err != nil { + return "" + } + return engine.DefaultProviderFilter(ctx) } diff --git a/internal/config/security_test.go b/internal/config/security_test.go index 5dca0448..81d18db5 100644 --- a/internal/config/security_test.go +++ b/internal/config/security_test.go @@ -62,7 +62,9 @@ func TestMergeSettings_AllowedToolsAppend(t *testing.T) { func TestValidateSettings_ValidConfig(t *testing.T) { // This test uses the global catalog test setup from main_test.go - t.Setenv("HAWK_CONFIG_DIR", t.TempDir()) + dir := t.TempDir() + t.Setenv("HAWK_CONFIG_DIR", dir) + t.Setenv("EYRIE_CONFIG_DIR", dir) s := Settings{ MaxBudgetUSD: 10.0, } diff --git a/internal/config/settings.go b/internal/config/settings.go index 7dade60e..439b48e4 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -15,17 +15,13 @@ import ( "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/storage" - "github.com/GrayCodeAI/eyrie/catalog" - eyriecfg "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/credentials" - "github.com/GrayCodeAI/eyrie/runtime" - "github.com/GrayCodeAI/eyrie/setup" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" "github.com/GrayCodeAI/hawk/internal/types" ) -func fetchModelsViaRuntime(ctx context.Context, provider string) ([]catalog.ModelCatalogEntry, error) { - return runtime.ModelsForProvider(ctx, provider) +func fetchModelsViaRuntime(ctx context.Context, provider string) ([]EngineModel, error) { + return ListEngineModels(ctx, provider, false) } // Settings holds hawk configuration. @@ -402,10 +398,10 @@ func SetGlobalSetting(key, value string) error { normalized := normalizeSettingKey(key) // Hawk: reject API key persistence to disk if _, ok := apiKeyProviderFromSettingKey(normalized); ok { - return fmt.Errorf("API keys are not stored in settings.json. Save via /config (%s)", credentials.PlatformSecretStoreName()) + return fmt.Errorf("API keys are not stored in settings.json. Save via /config (%s)", CredentialStoreName()) } if normalized == "apikey" { - return fmt.Errorf("API keys are not stored in settings.json. Save via /config (%s)", credentials.PlatformSecretStoreName()) + return fmt.Errorf("API keys are not stored in settings.json. Save via /config (%s)", CredentialStoreName()) } switch normalized { case "model": @@ -526,29 +522,24 @@ func ProviderAPIKeyEnv(provider string) string { if env := SetupGatewayCredentialEnv(provider); env != "" { return env } - compiled := compiledCatalogOrBootstrap() - if compiled == nil { - return "" - } - return catalog.PrimaryAPIKeyEnvForProvider(compiled, runtime.CatalogProviderID(provider)) + return "" } // EnvKeyStatus returns set, empty, or local from the OS credential store. func EnvKeyStatus(provider string) string { - compiled := compiledCatalogOrBootstrap() - if compiled == nil { + engine, err := newEyrieEngine() + if err != nil { return "empty" } - provider = runtime.CatalogProviderID(provider) - envs := catalog.APIKeyEnvsForProvider(compiled, provider) - if len(envs) == 0 { - return "local" + status, err := engine.CredentialStatus(context.Background(), provider) + if err != nil { + return "empty" } - ctx := context.Background() - for _, env := range envs { - if credentials.HasSecret(ctx, env) { - return "set" - } + if status.Configured { + return "set" + } + if strings.TrimSpace(status.EnvVar) == "" { + return "local" } return "empty" } @@ -568,43 +559,16 @@ func AllEnvKeyStatus() string { return strings.Join(parts, ", ") } -// LoadAPIKeysFromStore reads API keys for all eyrie catalog providers from the OS secret store. -func LoadAPIKeysFromStore() map[string]string { - keys := make(map[string]string) - for _, p := range AllCatalogProviders() { - if v := APIKeyForProvider(p); v != "" { - keys[p] = v - } - } - return keys -} - -// APIKeyForProvider reads the API key for a provider from the OS secret store. -func APIKeyForProvider(provider string) string { - compiled := compiledCatalogOrBootstrap() - if compiled == nil { - return "" - } - provider = runtime.CatalogProviderID(provider) - ctx := context.Background() - for _, env := range catalog.APIKeyEnvsForProvider(compiled, provider) { - if v := credentials.LookupSecret(ctx, env); v != "" { - return v - } - } - for _, env := range runtime.CredentialEnvKeys(provider) { - if v := credentials.LookupSecret(ctx, env); v != "" { - return v - } - } - return "" -} - func providerCredentialEnvAliases(provider string) []string { primary := strings.TrimSpace(ProviderAPIKeyEnv(provider)) seen := map[string]bool{} var out []string - for _, env := range runtime.CredentialEnvKeys(provider) { + engine, _ := newEyrieEngine() + var aliases []string + if engine != nil { + aliases = engine.CredentialEnvKeys(provider) + } + for _, env := range aliases { env = strings.TrimSpace(env) if env == "" || env == primary || seen[env] { continue @@ -621,8 +585,8 @@ func providerCredentialEnvAliases(provider string) []string { // FetchModelsForProvider returns models from the eyrie catalog (dynamic; no hawk hardcoded lists). // RefreshModelCatalogV1 is the explicit network refresh boundary. -func FetchModelsForProvider(provider string) ([]catalog.ModelCatalogEntry, error) { - provider = runtime.CatalogProviderID(provider) +func FetchModelsForProvider(provider string) ([]EngineModel, error) { + provider = eyrieengine.NormalizeProviderID(provider) if provider == "" { return nil, fmt.Errorf("no provider specified") } @@ -639,11 +603,11 @@ func FetchModelsForProvider(provider string) ([]catalog.ModelCatalogEntry, error } // Custom OpenAI-compatible providers: single model from settings, not hawk catalog data. for _, cp := range LoadSettings().CustomProviders { - if runtime.ActiveProviderID(cp.Name) != provider { + if eyrieengine.NormalizeProviderID(cp.Name) != provider { continue } if id := strings.TrimSpace(cp.Model); id != "" { - return []catalog.ModelCatalogEntry{{ + return []EngineModel{{ ID: id, DisplayName: id, }}, nil @@ -652,10 +616,36 @@ func FetchModelsForProvider(provider string) ([]catalog.ModelCatalogEntry, error return nil, fmt.Errorf("no models found for provider %s in eyrie catalog (check API keys; hawk will refresh automatically on next start)", provider) } -func refreshModelCatalog(ctx context.Context, force bool) (*catalog.RefreshResult, error) { - return setup.DiscoverModelCatalogWithOptions(ctx, eyriecfg.DiscoveryCredentials(ctx), setup.DiscoverModelCatalogOptions{ - ForceRefresh: force, - }) +// FetchModelsForProviderWithSettings resolves cached models using one +// invocation's effective settings, including its custom gateways. +func FetchModelsForProviderWithSettings(ctx context.Context, settings Settings, provider string) ([]EngineModel, error) { + provider = eyrieengine.NormalizeProviderID(provider) + if provider == "" { + return nil, fmt.Errorf("no provider specified") + } + engine, engineErr := NewEyrieEngineForSettings(settings) + if engineErr != nil { + return nil, engineErr + } + models, err := engine.ListModels(ctx, provider, false) + if err == nil && len(models) > 0 { + return models, nil + } + if refreshed, refreshErr := engine.ListModels(ctx, provider, true); refreshErr == nil && len(refreshed) > 0 { + return refreshed, nil + } + if err != nil { + return nil, err + } + return nil, fmt.Errorf("no models found for provider %s in eyrie catalog", provider) +} + +func refreshModelCatalog(ctx context.Context, _ bool) (eyrieengine.CatalogSnapshot, error) { + engine, err := newEyrieEngine() + if err != nil { + return eyrieengine.CatalogSnapshot{}, err + } + return engine.RefreshCatalog(ctx, "") } // RefreshModelCatalogV1 asks eyrie to refresh the remote catalog and provider APIs using env API keys. @@ -667,19 +657,21 @@ func RefreshModelCatalogV1(ctx context.Context) (string, error) { if err != nil { return "", err } - return result.DiscoverReport(), nil + return formatCatalogSnapshot(result), nil } -func loadEyrieCatalogV1(ctx context.Context, refreshRemote bool) (*catalog.CompiledCatalog, error) { - if refreshRemote { - result, err := setup.DiscoverModelCatalog(ctx, eyriecfg.DiscoveryCredentials(ctx)) - if err != nil { - return nil, err - } - return result.Compiled, nil +// RefreshModelCatalogV1WithSettings refreshes through an invocation-scoped +// engine, so --settings custom gateways participate without global state. +func RefreshModelCatalogV1WithSettings(ctx context.Context, settings Settings) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + engine, err := NewEyrieEngineForSettings(settings) + if err != nil { + return "", err + } + result, err := engine.RefreshCatalog(ctx, "") + if err != nil { + return "", err } - return catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ - CachePath: catalog.DefaultCachePath(), - RequireCache: false, - }) + return formatCatalogSnapshot(result), nil } diff --git a/internal/config/settings_extra_test.go b/internal/config/settings_extra_test.go deleted file mode 100644 index c3468859..00000000 --- a/internal/config/settings_extra_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package config - -import ( - "context" - "testing" - - "github.com/GrayCodeAI/eyrie/credentials" -) - -func TestAPIKeyForProvider(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) - - ctx := context.Background() - if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-test-key"); err != nil { - t.Fatal(err) - } - key := APIKeyForProvider("openai") - if key != "sk-test-key" { - t.Errorf("APIKeyForProvider = %q, want sk-test-key", key) - } -} - -func TestAPIKeyForProvider_Missing(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) - - key := APIKeyForProvider("nonexistent_provider_xyz") - if key != "" { - t.Errorf("expected empty for missing key, got %q", key) - } -} - -func TestAllEnvKeyStatus(t *testing.T) { - result := AllEnvKeyStatus() - if result == "" { - t.Error("AllEnvKeyStatus should return status string") - } -} diff --git a/internal/config/settings_status_test.go b/internal/config/settings_status_test.go new file mode 100644 index 00000000..66672bdc --- /dev/null +++ b/internal/config/settings_status_test.go @@ -0,0 +1,30 @@ +package config + +import ( + "context" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestEnvKeyStatusUsesEyrieCredentialStatus(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + if got := EnvKeyStatus("openai"); got != "empty" { + t.Fatalf("EnvKeyStatus without credential = %q, want empty", got) + } + if err := store.Set(context.Background(), credentials.AccountForEnv("OPENAI_API_KEY"), "sk-live-status-test-1234567890"); err != nil { + t.Fatal(err) + } + if got := EnvKeyStatus("openai"); got != "set" { + t.Fatalf("EnvKeyStatus with credential = %q, want set", got) + } +} + +func TestAllEnvKeyStatus(t *testing.T) { + if result := AllEnvKeyStatus(); result == "" { + t.Fatal("AllEnvKeyStatus should return a status string") + } +} diff --git a/internal/config/setup_status.go b/internal/config/setup_status.go index 6df7ac28..6bef8dea 100644 --- a/internal/config/setup_status.go +++ b/internal/config/setup_status.go @@ -3,8 +3,6 @@ package config import ( "context" "strings" - - "github.com/GrayCodeAI/eyrie/runtime" ) // SetupState is a single evaluation of first-run /config requirements. @@ -20,8 +18,7 @@ func EvaluateSetup(ctx context.Context) SetupState { if ctx == nil { ctx = context.Background() } - PrepareCredentialDiscovery(ctx) - return evaluateSetupFrom(runtime.HasConfiguredDeployment(ctx), HasSelectedModel()) + return evaluateSetupFrom(HasConfiguredDeployment(ctx), HasSelectedModel()) } // EvaluateSetupCached uses the in-memory credential snapshot (fast; for TUI hot paths). @@ -53,7 +50,8 @@ func HasConfiguredDeployment(ctx context.Context) bool { if ctx == nil { ctx = context.Background() } - return runtime.HasConfiguredDeployment(ctx) + engine, err := newEyrieEngine() + return err == nil && engine.EffectiveSelection(ctx, SelectionOptions{}).HasConfiguredDeployment } // HasSelectedModel reports whether eyrie provider.json has a selected model. diff --git a/internal/config/setup_status_test.go b/internal/config/setup_status_test.go index 07519af5..d7d704db 100644 --- a/internal/config/setup_status_test.go +++ b/internal/config/setup_status_test.go @@ -99,7 +99,7 @@ func TestSyncSelectionWithCredentials_ClearsStaleModel(t *testing.T) { if err := SetActiveProvider(ctx, "openrouter"); err != nil { t.Fatal(err) } - if err := SetActiveModel(ctx, "moonshotai/kimi-k2.6"); err != nil { + if err := SetActiveModel(ctx, "gpt-4o"); err != nil { t.Fatal(err) } SyncSelectionWithCredentials(ctx) @@ -127,12 +127,12 @@ func TestSyncSelectionWithCredentials_KeepsWhenGatewayHasKey(t *testing.T) { if err := SetActiveProvider(ctx, "openrouter"); err != nil { t.Fatal(err) } - if err := SetActiveModel(ctx, "openrouter/auto"); err != nil { + if err := SetActiveModel(ctx, "gpt-4o"); err != nil { t.Fatal(err) } SyncSelectionWithCredentials(ctx) - if ActiveModel(ctx) != "openrouter/auto" { + if ActiveModel(ctx) != "openai/gpt-4o" { t.Fatalf("model = %q", ActiveModel(ctx)) } } diff --git a/internal/config/ui_cache.go b/internal/config/ui_cache.go index 1d582e40..bf013cdd 100644 --- a/internal/config/ui_cache.go +++ b/internal/config/ui_cache.go @@ -3,32 +3,31 @@ package config import ( "context" "sort" - "strings" "sync" "time" - - "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/credentials" ) var uiCacheMu sync.RWMutex var ( - cachedCompiled *catalog.CompiledCatalog credConfigured map[string]bool credHasAny bool credValid bool credSnapMu sync.Mutex credSnapAt time.Time - credSnapStore map[string]string + credSnapStore map[string]gatewayCredentialState ) const gatewayCredSnapshotTTL = 5 * time.Second +type gatewayCredentialState struct { + credential bool + deployment bool +} + // InvalidateConfigUICache drops in-memory catalog and credential snapshots (call after refresh/key changes). func InvalidateConfigUICache() { uiCacheMu.Lock() - cachedCompiled = nil credValid = false credConfigured = nil uiCacheMu.Unlock() @@ -44,14 +43,16 @@ func RefreshConfigCredSnapshot(ctx context.Context) { if ctx == nil { ctx = context.Background() } - PrepareCredentialDiscovery(ctx) stored := gatewayCredentialSnapshot(ctx) gateways := AllSetupGateways() configured := make(map[string]bool, len(gateways)) hasAny := false for _, p := range gateways { - if gatewayConfiguredFromStored(p, stored) { + state := stored[p] + if state.credential { configured[p] = true + } + if state.deployment { hasAny = true } } @@ -63,7 +64,7 @@ func RefreshConfigCredSnapshot(ctx context.Context) { } // gatewayCredentialSnapshot loads only setup-gateway env keys (not the full discovery list). -func gatewayCredentialSnapshot(ctx context.Context) map[string]string { +func gatewayCredentialSnapshot(ctx context.Context) map[string]gatewayCredentialState { credSnapMu.Lock() if credSnapStore != nil && time.Since(credSnapAt) < gatewayCredSnapshotTTL { out := credSnapStore @@ -72,14 +73,13 @@ func gatewayCredentialSnapshot(ctx context.Context) map[string]string { } credSnapMu.Unlock() - out := make(map[string]string) - for _, gw := range AllSetupGateways() { - for _, env := range credentialEnvKeysForTarget(gw) { - if _, ok := out[env]; ok { - continue - } - if secret := credentials.LookupSecret(ctx, env); secret != "" { - out[env] = secret + out := make(map[string]gatewayCredentialState) + engine, err := newEyrieEngine() + if err == nil { + for _, gateway := range engine.Gateways(ctx) { + out[gateway.ID] = gatewayCredentialState{ + credential: gateway.CredentialConfigured, + deployment: gateway.DeploymentConfigured, } } } @@ -90,15 +90,6 @@ func gatewayCredentialSnapshot(ctx context.Context) map[string]string { return out } -func gatewayConfiguredFromStored(providerID string, stored map[string]string) bool { - for _, env := range credentialEnvKeysForTarget(providerID) { - if strings.TrimSpace(stored[env]) != "" { - return true - } - } - return false -} - func ensureCredSnapshot(ctx context.Context) { uiCacheMu.RLock() valid := credValid @@ -141,18 +132,3 @@ func hasConfiguredDeploymentCached(ctx context.Context) bool { defer uiCacheMu.RUnlock() return credHasAny } - -func storeCompiledCatalog(compiled *catalog.CompiledCatalog) { - uiCacheMu.Lock() - cachedCompiled = compiled - uiCacheMu.Unlock() -} - -func cachedCompiledCatalog() (*catalog.CompiledCatalog, bool) { - uiCacheMu.RLock() - defer uiCacheMu.RUnlock() - if cachedCompiled == nil { - return nil, false - } - return cachedCompiled, true -} diff --git a/internal/config/validator.go b/internal/config/validator.go index 893404ee..d8fa0692 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -5,8 +5,6 @@ import ( "context" "fmt" "strings" - - "github.com/GrayCodeAI/eyrie/credentials" ) // ValidationError represents a config validation error. @@ -67,10 +65,10 @@ func ValidateSettings(s Settings) ValidationResult { // Hawk: validate API key is in the OS secret store (not in settings) if activeProvider != "" { envKey := ProviderAPIKeyEnv(activeProvider) - if envKey != "" && APIKeyForProvider(activeProvider) == "" { + if envKey != "" && EnvKeyStatus(activeProvider) != "set" { errors = append(errors, ValidationError{ Field: "apiKey", - Message: fmt.Sprintf("save your %s API key with /config (%s)", activeProvider, credentials.PlatformSecretStoreName()), + Message: fmt.Sprintf("save your %s API key with /config (%s)", activeProvider, CredentialStoreName()), }) } } diff --git a/internal/config/xiaomi_setup.go b/internal/config/xiaomi_setup.go index 83d354e5..c21f437a 100644 --- a/internal/config/xiaomi_setup.go +++ b/internal/config/xiaomi_setup.go @@ -2,28 +2,24 @@ package config import ( "context" - - "github.com/GrayCodeAI/eyrie/runtime" ) const ProviderXiaomiTokenPlan = "xiaomi_mimo_token_plan" // #nosec G101 -- provider ID string, not a credential // NeedsXiaomiTokenPlanRegion reports whether the Token Plan gateway still needs a cluster pick. func NeedsXiaomiTokenPlanRegion(providerID string) bool { - return runtime.GatewayNeedsRegion(providerID) + _, required := EngineGatewayRegion(providerID) + return required } -// SetXiaomiTokenPlanRegion persists region (cn, sgp, ams) and syncs env for probe/discovery. +// SetXiaomiTokenPlanRegion persists region (cn, sgp, ams). Eyrie reads the +// provider state directly when probing; Hawk does not mutate process env. func SetXiaomiTokenPlanRegion(region string) error { - return runtime.SetGatewayRegion(ProviderXiaomiTokenPlan, region) + return SetEngineGatewayRegion(context.Background(), ProviderXiaomiTokenPlan, region) } // XiaomiTokenPlanRegionLabel returns the saved cluster id for UI (cn, sgp, ams) or "" if unset. func XiaomiTokenPlanRegionLabel() string { - return runtime.GatewayRegionLabel(ProviderXiaomiTokenPlan) -} - -// ApplyXiaomiTokenPlanRegionEnv sets process env from provider.json before credential probe. -func ApplyXiaomiTokenPlanRegionEnv(ctx context.Context) { - runtime.ApplyGatewayEnv(ctx, ProviderXiaomiTokenPlan) + label, _ := EngineGatewayRegion(ProviderXiaomiTokenPlan) + return label } diff --git a/internal/config/xiaomi_setup_test.go b/internal/config/xiaomi_setup_test.go index 768539f0..b0b83bfd 100644 --- a/internal/config/xiaomi_setup_test.go +++ b/internal/config/xiaomi_setup_test.go @@ -1,7 +1,7 @@ package config import ( - "context" + "os" "testing" eyriecfg "github.com/GrayCodeAI/eyrie/config" @@ -11,8 +11,10 @@ func TestSetXiaomiTokenPlanRegion_ClearsStaleBaseHost(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("HAWK_CONFIG_DIR", dir) + t.Setenv("EYRIE_CONFIG_DIR", dir) + t.Setenv("XIAOMI_MIMO_TOKEN_PLAN_BASE_URL", "https://caller-owned.example.test/v1") cfg := &eyriecfg.ProviderConfig{ - Version: "2", + Version: "1", XiaomiMimoTokenPlanRegion: "cn", XiaomiMimoTokenPlanBaseURL: "https://token-plan-cn.xiaomimimo.com/v1", } @@ -26,22 +28,25 @@ func TestSetXiaomiTokenPlanRegion_ClearsStaleBaseHost(t *testing.T) { if loaded.XiaomiMimoTokenPlanRegion != "sgp" { t.Fatalf("region = %q", loaded.XiaomiMimoTokenPlanRegion) } + if got := os.Getenv("XIAOMI_MIMO_TOKEN_PLAN_BASE_URL"); got != "https://caller-owned.example.test/v1" { + t.Fatalf("SetXiaomiTokenPlanRegion mutated process env: %q", got) + } // want := "https://token-plan-sgp.xiaomimimo.com/v1" // if loaded.XiaomiMimoTokenPlanBaseURL != want { // t.Fatalf("base = %q, want %s", loaded.XiaomiMimoTokenPlanBaseURL, want) // } - ApplyXiaomiTokenPlanRegionEnv(context.Background()) } func TestNeedsXiaomiTokenPlanRegion_InvalidAndMissing(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("HAWK_CONFIG_DIR", dir) + t.Setenv("EYRIE_CONFIG_DIR", dir) if !NeedsXiaomiTokenPlanRegion(ProviderXiaomiTokenPlan) { t.Fatal("expected true when no config file") } - if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{Version: "2", XiaomiMimoTokenPlanRegion: "tokyo"}, ""); err != nil { + if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{Version: "1", XiaomiMimoTokenPlanRegion: "tokyo"}, ""); err != nil { t.Fatal(err) } if !NeedsXiaomiTokenPlanRegion(ProviderXiaomiTokenPlan) { diff --git a/internal/config/zai_setup.go b/internal/config/zai_setup.go index 4d19793f..bd0b6231 100644 --- a/internal/config/zai_setup.go +++ b/internal/config/zai_setup.go @@ -2,8 +2,6 @@ package config import ( "context" - - "github.com/GrayCodeAI/eyrie/runtime" ) const ( @@ -13,21 +11,18 @@ const ( // NeedsZAIRegion reports whether the Z.AI gateway still needs a region pick for the chosen plan. func NeedsZAIRegion(providerID string) bool { - return runtime.GatewayNeedsRegion(providerID) + _, required := EngineGatewayRegion(providerID) + return required } -// SetZAIRegion persists the region (international or cn) for the given Z.AI gateway and syncs env + derived base. +// SetZAIRegion persists the region (international or cn) for the given Z.AI +// gateway. Eyrie reads provider state directly without process-env mutation. func SetZAIRegion(providerID, region string) error { - return runtime.SetGatewayRegion(providerID, region) + return SetEngineGatewayRegion(context.Background(), providerID, region) } // ZAIRegionLabel returns the saved region label or "". func ZAIRegionLabel(providerID string) string { - return runtime.GatewayRegionLabel(providerID) -} - -// ApplyZAIRegionEnv sets process envs from provider.json before probe/fetch/chat. -func ApplyZAIRegionEnv(ctx context.Context) { - runtime.ApplyGatewayEnv(ctx, ProviderZAIPayg) - runtime.ApplyGatewayEnv(ctx, ProviderZAICoding) + label, _ := EngineGatewayRegion(providerID) + return label } diff --git a/internal/daemon/api_types.go b/internal/daemon/api_types.go index 5ee51904..33ab85f9 100644 --- a/internal/daemon/api_types.go +++ b/internal/daemon/api_types.go @@ -25,6 +25,7 @@ type SessionDetailResponse struct { UpdatedAt time.Time `json:"updated_at"` Model string `json:"model"` Provider string `json:"provider"` + Agent string `json:"agent,omitempty"` CWD string `json:"cwd"` Name string `json:"name"` MessageCount int `json:"message_count"` diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 919633a1..f21d1743 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -2,8 +2,11 @@ package daemon import ( "context" + "crypto/rand" "crypto/subtle" + "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -17,6 +20,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/netutil" + hawksession "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -26,6 +30,23 @@ const maxRequestBodyBytes = 1 << 20 // The caller (cmd package) provides this, wiring system prompts, tools, keys. type SessionFactory func(req ChatRequest) (*engine.Session, error) +// InvalidChatRequestError lets a SessionFactory reject a request without +// exposing internal construction errors as part of the public API. Factories +// should use it for user-controlled values such as an unknown agent persona. +type InvalidChatRequestError struct { + Message string + Err error +} + +func (e *InvalidChatRequestError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return e.Message +} + +func (e *InvalidChatRequestError) Unwrap() error { return e.Err } + // version is set via SetVersion from main.go at startup. var version = "0.0.0" @@ -34,19 +55,23 @@ func SetVersion(v string) { version = v } // Server is the hawk daemon HTTP server for programmatic/CI access. type Server struct { - addr string - mux *http.ServeMux - server *http.Server - startedAt time.Time - sessions sync.Map // sessionID -> *Session - newSession SessionFactory - apiKey string - gateways *gatewayManager - - // readyFn reports whether the daemon's dependencies (session store and - // provider connectivity) are initialized and the server can serve real - // traffic. It is consulted by GET /v1/ready. When nil, readiness falls - // back to "session factory wired" (see Ready). Set via SetReadyFn. + addr string + mux *http.ServeMux + server *http.Server + startedAt time.Time + sessions sync.Map // sessionID -> *Session + // A fixed stripe set serializes continuation and deletion for the same + // durable ID without allowing arbitrary client-supplied IDs to grow a lock + // map for the lifetime of the daemon. + sessionLocks [64]sync.Mutex + newSession SessionFactory + apiKey string + gateways *gatewayManager + + // readyFn reports whether Eyrie's preflight/readiness dependencies are + // initialized and the server can serve real traffic. It is consulted by + // GET /v1/ready. A nil probe fails closed: a session factory alone does not + // prove that Eyrie's catalog, credentials, and model selection are ready. readyMu sync.RWMutex readyFn func() (bool, string) @@ -112,6 +137,7 @@ type Session struct { LastUsed time.Time `json:"last_used"` Turns int `json:"turns"` CWD string `json:"cwd"` + Agent string `json:"agent,omitempty"` } // HealthResponse is the JSON response from GET /v1/health. @@ -259,8 +285,9 @@ func (s *Server) SetReadyFn(fn func() (bool, string)) { s.readyMu.Unlock() } -// ready evaluates readiness using the installed probe, falling back to the -// default check (session factory wired) when none is set. +// ready evaluates readiness using the installed Eyrie probe. It fails closed +// when the engine is absent or the composition root did not install a probe; +// merely having a factory does not prove provider readiness. func (s *Server) ready() (bool, string) { s.readyMu.RLock() fn := s.readyFn @@ -268,13 +295,10 @@ func (s *Server) ready() (bool, string) { if fn != nil { return fn() } - // Default readiness: the session store map is always initialized once New - // runs, so the remaining dependency is provider connectivity, which the - // cmd layer wires as the session factory. No factory => cannot serve chat. if s.newSession == nil { return false, "engine not configured" } - return true, "" + return false, "Eyrie readiness probe not configured" } func (s *Server) routes() { @@ -405,13 +429,109 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } start := time.Now() + requestedID := strings.TrimSpace(req.SessionID) + if requestedID != "" && !validSessionID(requestedID) { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: "invalid session id: use 1-128 alphanumeric, dash, underscore, or dot characters", + Code: "invalid_session_id", + }) + return + } + + sessionID := requestedID + if sessionID == "" { + var idErr error + sessionID, idErr = newSessionID() + if idErr != nil { + slog.Error("session id generation failed", "err", idErr) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "session creation failed"}) + return + } + } + + lock := s.sessionLock(sessionID) + lock.Lock() + defer lock.Unlock() + + var saved *hawksession.Session + if requestedID != "" { + var loadErr error + saved, loadErr = hawksession.Load(sessionID) + if loadErr != nil { + if !errors.Is(loadErr, hawksession.ErrNotFound) { + slog.Error("load continuation session failed", "err", loadErr, "session_id", sessionID) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{ + Error: "session load failed", + Code: "session_load_failed", + }) + return + } + writeJSON(w, http.StatusNotFound, ErrorResponse{ + Error: "session not found", + Code: "session_not_found", + }) + return + } + if strings.TrimSpace(req.Model) == "" { + req.Model = saved.Model + } + if strings.TrimSpace(req.Agent) == "" { + req.Agent = saved.Agent + } + } + + requestedCWD := strings.TrimSpace(req.CWD) + switch { + case requestedCWD != "": + canonicalCWD, cwdErr := canonicalSessionCWD(requestedCWD) + if cwdErr != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: "invalid cwd", + Code: "invalid_cwd", + Details: cwdErr.Error(), + }) + return + } + req.CWD = canonicalCWD + case saved != nil && saved.CWD != "": + // CWD is durable session metadata. Inherit it on continuation even + // if that directory has since been removed. + req.CWD = saved.CWD + default: + canonicalCWD, cwdErr := canonicalSessionCWD("") + if cwdErr != nil { + slog.Error("resolve daemon cwd failed", "err", cwdErr) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "session creation failed"}) + return + } + req.CWD = canonicalCWD + } + + req.SessionID = sessionID + req.Agent = strings.TrimSpace(req.Agent) sess, err := s.newSession(req) if err != nil { + var requestErr *InvalidChatRequestError + if errors.As(err, &requestErr) { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: requestErr.Message, + Code: "invalid_chat_request", + }) + return + } slog.Error("session create failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"}) return } + if sess == nil { + slog.Error("session factory returned nil session") + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"}) + return + } + if saved != nil { + sess.LoadMessages(hawksession.ToRuntimeMessages(saved.Messages)) + } // Set autonomy if req.Autonomy != "" { @@ -436,6 +556,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } sess.AddUser(req.Prompt) + isSSE := wantsSSE(r) + if isSSE { + // Publish the user turn before exposing the session ID in streaming + // response headers. Even if the client disconnects mid-stream, the ID + // it received remains retrievable and resumable. + if saveErr := persistDaemonSession(sessionID, req, sess, saved, start); saveErr != nil { + slog.Error("persist streaming session start failed", "err", saveErr, "session_id", sessionID) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "session persistence failed"}) + return + } + } ctx := r.Context() events, err := sess.Stream(ctx) @@ -446,13 +577,15 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } // SSE streaming mode - if wantsSSE(r) { + if isSSE { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Hawk-Session-ID", sessionID) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") flusher, _ := w.(http.Flusher) + var totalIn, totalOut, turns int for ev := range events { switch ev.Type { @@ -463,13 +596,36 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, "data: %s\n", line) } _, _ = fmt.Fprint(w, "\n") - case "done": - _, _ = fmt.Fprintf(w, "event: done\ndata: {}\n\n") + case "usage": + if ev.Usage != nil { + totalIn += ev.Usage.PromptTokens + totalOut += ev.Usage.CompletionTokens + turns++ + } } if flusher != nil { flusher.Flush() } } + if saveErr := persistDaemonSession(sessionID, req, sess, saved, start); saveErr != nil { + slog.Error("persist streaming session failed", "err", saveErr, "session_id", sessionID) + _, _ = fmt.Fprint(w, "event: error\ndata: session persistence failed\n\n") + if flusher != nil { + flusher.Flush() + } + return + } + s.trackSession(sessionID, req, saved, start, turns) + doneData, _ := json.Marshal(map[string]interface{}{ + "session_id": sessionID, + "tokens_in": totalIn, + "tokens_out": totalOut, + "turns_taken": turns, + }) + _, _ = fmt.Fprintf(w, "event: done\ndata: %s\n\n", doneData) + if flusher != nil { + flusher.Flush() + } return } @@ -490,14 +646,13 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } } - sessionID := fmt.Sprintf("daemon-%d", start.UnixMilli()) - s.sessions.Store(sessionID, &Session{ - ID: sessionID, - CreatedAt: start, - LastUsed: time.Now(), - Turns: turns, - CWD: req.CWD, - }) + if saveErr := persistDaemonSession(sessionID, req, sess, saved, start); saveErr != nil { + slog.Error("persist session failed", "err", saveErr, "session_id", sessionID) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "session persistence failed"}) + return + } + s.trackSession(sessionID, req, saved, start, turns) + w.Header().Set("X-Hawk-Session-ID", sessionID) writeJSON(w, http.StatusOK, ChatResponse{ SessionID: sessionID, @@ -509,6 +664,105 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { }) } +func validSessionID(id string) bool { + if len(id) == 0 || len(id) > 128 { + return false + } + for _, c := range id { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { + return false + } + } + return true +} + +func newSessionID() (string, error) { + var entropy [16]byte + if _, err := rand.Read(entropy[:]); err != nil { + return "", err + } + return "daemon-" + hex.EncodeToString(entropy[:]), nil +} + +func canonicalSessionCWD(cwd string) (string, error) { + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return "", err + } + } + abs, err := filepath.Abs(cwd) + if err != nil { + return "", err + } + canonical, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", err + } + info, err := os.Stat(canonical) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("%s is not a directory", canonical) + } + return canonical, nil +} + +func (s *Server) sessionLock(id string) *sync.Mutex { + // FNV-1a is sufficient here: the hash only selects a synchronization + // stripe; session IDs remain validated and are never trusted as paths. + const offset64 = uint64(14695981039346656037) + const prime64 = uint64(1099511628211) + hash := offset64 + for i := 0; i < len(id); i++ { + hash ^= uint64(id[i]) + hash *= prime64 + } + return &s.sessionLocks[hash%uint64(len(s.sessionLocks))] +} + +func persistDaemonSession(id string, req ChatRequest, sess *engine.Session, previous *hawksession.Session, startedAt time.Time) error { + createdAt := startedAt + name := "" + if previous != nil { + createdAt = previous.CreatedAt + name = previous.Name + } + return hawksession.Save(&hawksession.Session{ + ID: id, + Model: sess.Model(), + Provider: sess.Provider(), + Agent: req.Agent, + CWD: req.CWD, + Name: name, + Messages: hawksession.FromRuntimeMessages(sess.RawMessages()), + CreatedAt: createdAt, + }) +} + +func (s *Server) trackSession(id string, req ChatRequest, previous *hawksession.Session, startedAt time.Time, turns int) { + createdAt := startedAt + if previous != nil && !previous.CreatedAt.IsZero() { + createdAt = previous.CreatedAt + } + if current, ok := s.sessions.Load(id); ok { + if active, activeOK := current.(*Session); activeOK { + turns += active.Turns + } + } + s.sessions.Store(id, &Session{ + ID: id, + CreatedAt: createdAt, + LastUsed: time.Now(), + Turns: turns, + CWD: req.CWD, + Agent: req.Agent, + }) +} + func (s *Server) handleListSessions(w http.ResponseWriter, _ *http.Request) { var sessions []*Session s.sessions.Range(func(_, v any) bool { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 6f12aece..4abdef26 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -4,13 +4,18 @@ import ( "bytes" "context" "encoding/json" + "io" "net" "net/http" + "os" + "path/filepath" "strings" "testing" "time" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/session" + "github.com/GrayCodeAI/hawk/internal/storage" "github.com/GrayCodeAI/hawk/internal/testutil" ) @@ -88,10 +93,10 @@ func TestDaemon_Ready(t *testing.T) { wantReady: false, }, { - name: "engine wired is ready", + name: "engine wired without Eyrie probe is not ready", factory: factory, - wantStatus: http.StatusOK, - wantReady: true, + wantStatus: http.StatusServiceUnavailable, + wantReady: false, }, { name: "custom probe forces not ready", @@ -232,6 +237,7 @@ func TestDaemon_RejectsUnknownFields(t *testing.T) { } func TestDaemon_Chat_WithEngine(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) factory := func(req ChatRequest) (*engine.Session, error) { sess := engine.NewSession("", "test-model", "you are helpful", nil) if err := sess.SetMaxTurns(1); err != nil { @@ -255,6 +261,243 @@ func TestDaemon_Chat_WithEngine(t *testing.T) { } } +func daemonTestSessionFactory(seen chan<- ChatRequest) SessionFactory { + return func(req ChatRequest) (*engine.Session, error) { + if seen != nil { + seen <- req + } + model := req.Model + if model == "" { + model = "test-model" + } + sess := engine.NewSessionWithClient( + engine.NewMockClientForTest(), + "test-provider", + model, + "test system prompt", + nil, + false, + ) + if err := sess.SetMaxTurns(1); err != nil { + return nil, err + } + return sess, nil + } +} + +func postDaemonChat(t *testing.T, addr string, request ChatRequest, accept string) (*http.Response, ChatResponse) { + t.Helper() + body, err := json.Marshal(request) + if err != nil { + t.Fatalf("marshal chat request: %v", err) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://"+addr+"/v1/chat", bytes.NewReader(body)) + if err != nil { + t.Fatalf("new chat request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if accept != "" { + req.Header.Set("Accept", accept) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST /v1/chat: %v", err) + } + var decoded ChatResponse + if accept == "" && resp.StatusCode == http.StatusOK { + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + resp.Body.Close() + t.Fatalf("decode chat response: %v", err) + } + } + return resp, decoded +} + +func TestDaemon_ChatPersistsRetrievableSessionAndRequestMetadata(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) + requestedCWD := t.TempDir() + canonicalCWD, err := canonicalSessionCWD(requestedCWD) + if err != nil { + t.Fatal(err) + } + seen := make(chan ChatRequest, 1) + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, daemonTestSessionFactory(seen)) + addr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + + resp, chat := postDaemonChat(t, addr, ChatRequest{ + Prompt: "persist me", + Model: "test-model-override", + CWD: requestedCWD, + Agent: "reviewer", + }, "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST /v1/chat status = %d, want 200", resp.StatusCode) + } + if chat.SessionID == "" || resp.Header.Get("X-Hawk-Session-ID") != chat.SessionID { + t.Fatalf("session ID response/header mismatch: body=%q header=%q", chat.SessionID, resp.Header.Get("X-Hawk-Session-ID")) + } + + factoryReq := <-seen + if factoryReq.SessionID != chat.SessionID || factoryReq.CWD != canonicalCWD || factoryReq.Agent != "reviewer" { + t.Fatalf("factory request = %+v, want durable ID, canonical cwd, and agent", factoryReq) + } + + saved, err := session.Load(chat.SessionID) + if err != nil { + t.Fatalf("returned session ID is not retrievable: %v", err) + } + if saved.CWD != canonicalCWD || saved.Agent != "reviewer" || saved.Model != "test-model-override" { + t.Fatalf("persisted metadata = %+v", saved) + } + if len(saved.Messages) != 2 || saved.Messages[0].Content != "persist me" || saved.Messages[1].Role != "assistant" { + t.Fatalf("persisted transcript = %+v, want user and assistant turns", saved.Messages) + } + + detailResp, err := http.Get("http://" + addr + "/v1/sessions/" + chat.SessionID) + if err != nil { + t.Fatalf("GET persisted session: %v", err) + } + defer detailResp.Body.Close() + var detail SessionDetailResponse + if err := json.NewDecoder(detailResp.Body).Decode(&detail); err != nil { + t.Fatalf("decode session detail: %v", err) + } + if detailResp.StatusCode != http.StatusOK || detail.ID != chat.SessionID || detail.MessageCount != 2 || detail.Agent != "reviewer" { + t.Fatalf("session detail status=%d body=%+v", detailResp.StatusCode, detail) + } +} + +func TestDaemon_ChatContinuationReusesDurableSession(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) + requestedCWD := t.TempDir() + seen := make(chan ChatRequest, 2) + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, daemonTestSessionFactory(seen)) + addr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + + firstResp, first := postDaemonChat(t, addr, ChatRequest{ + Prompt: "first turn", + Model: "continuation-model", + CWD: requestedCWD, + Agent: "reviewer", + }, "") + firstResp.Body.Close() + if firstResp.StatusCode != http.StatusOK { + t.Fatalf("first chat status = %d", firstResp.StatusCode) + } + + secondResp, second := postDaemonChat(t, addr, ChatRequest{ + Prompt: "second turn", + SessionID: first.SessionID, + }, "") + defer secondResp.Body.Close() + if secondResp.StatusCode != http.StatusOK { + t.Fatalf("continuation status = %d", secondResp.StatusCode) + } + if second.SessionID != first.SessionID { + t.Fatalf("continuation returned ID %q, want %q", second.SessionID, first.SessionID) + } + + <-seen // first request + continuedReq := <-seen + if continuedReq.Model != "continuation-model" || continuedReq.Agent != "reviewer" || continuedReq.CWD == "" { + t.Fatalf("continuation did not inherit persisted metadata: %+v", continuedReq) + } + saved, err := session.Load(first.SessionID) + if err != nil { + t.Fatalf("load continued session: %v", err) + } + if len(saved.Messages) != 4 { + t.Fatalf("continued transcript has %d messages, want 4: %+v", len(saved.Messages), saved.Messages) + } + if saved.Messages[2].Role != "user" || saved.Messages[2].Content != "second turn" { + t.Fatalf("continued user turn = %+v", saved.Messages[2]) + } +} + +func TestDaemon_ChatRejectsMissingContinuation(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, daemonTestSessionFactory(nil)) + addr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + + resp, _ := postDaemonChat(t, addr, ChatRequest{Prompt: "continue", SessionID: "does-not-exist"}, "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("missing continuation status = %d, want 404", resp.StatusCode) + } +} + +func TestDaemon_ChatDoesNotMisreportCorruptContinuationAsMissing(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) + if err := os.MkdirAll(storage.SessionsDir(), 0o750); err != nil { + t.Fatal(err) + } + const id = "corrupt-session" + if err := os.WriteFile(filepath.Join(storage.SessionsDir(), id+".jsonl"), []byte("{not-json}\n"), 0o600); err != nil { + t.Fatal(err) + } + + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, daemonTestSessionFactory(nil)) + addr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + + resp, _ := postDaemonChat(t, addr, ChatRequest{Prompt: "continue", SessionID: id}, "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("corrupt continuation status = %d, want 500", resp.StatusCode) + } + var body ErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Code != "session_load_failed" { + t.Fatalf("corrupt continuation code = %q, want session_load_failed", body.Code) + } +} + +func TestDaemon_ChatRejectsUnsafeSessionIDAndInvalidCWD(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, daemonTestSessionFactory(nil)) + addr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + + tests := []ChatRequest{ + {Prompt: "continue", SessionID: "../escape"}, + {Prompt: "start", CWD: t.TempDir() + "/missing"}, + } + for _, request := range tests { + resp, _ := postDaemonChat(t, addr, request, "") + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("request %+v status = %d, want 400", request, resp.StatusCode) + } + } +} + +func TestDaemon_ChatSSEExposesRetrievableSessionID(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, daemonTestSessionFactory(nil)) + addr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + + resp, _ := postDaemonChat(t, addr, ChatRequest{Prompt: "stream me"}, "text/event-stream") + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read SSE response: %v", err) + } + id := resp.Header.Get("X-Hawk-Session-ID") + if resp.StatusCode != http.StatusOK || id == "" || !strings.Contains(string(body), `"session_id":"`+id+`"`) { + t.Fatalf("SSE status=%d id=%q body=%q", resp.StatusCode, id, body) + } + if _, err := session.Load(id); err != nil { + t.Fatalf("SSE session ID is not retrievable: %v", err) + } +} + func TestDaemon_Chat_EmptyPrompt(t *testing.T) { srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, nil) addr := startTestDaemon(t, srv) diff --git a/internal/daemon/routes_sessions.go b/internal/daemon/routes_sessions.go index 9fa87e83..40a72d21 100644 --- a/internal/daemon/routes_sessions.go +++ b/internal/daemon/routes_sessions.go @@ -1,6 +1,8 @@ package daemon import ( + "errors" + "log/slog" "net/http" "os" "path/filepath" @@ -20,14 +22,17 @@ func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { }) return } + if !validSessionID(id) { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: "invalid session id", + Code: "invalid_id", + }) + return + } sess, err := session.Load(id) if err != nil { - writeJSON(w, http.StatusNotFound, ErrorResponse{ - Error: "session not found", - Code: "not_found", - Details: err.Error(), - }) + writeSessionLoadError(w, id, err) return } @@ -43,6 +48,7 @@ func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { UpdatedAt: sess.UpdatedAt, Model: sess.Model, Provider: sess.Provider, + Agent: sess.Agent, CWD: sess.CWD, Name: sess.Name, MessageCount: len(sess.Messages), @@ -60,6 +66,13 @@ func (s *Server) handleGetMessages(w http.ResponseWriter, r *http.Request) { }) return } + if !validSessionID(id) { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: "invalid session id", + Code: "invalid_id", + }) + return + } // Parse pagination params offset := 0 @@ -81,11 +94,7 @@ func (s *Server) handleGetMessages(w http.ResponseWriter, r *http.Request) { sess, err := session.Load(id) if err != nil { - writeJSON(w, http.StatusNotFound, ErrorResponse{ - Error: "session not found", - Code: "not_found", - Details: err.Error(), - }) + writeSessionLoadError(w, id, err) return } @@ -125,6 +134,21 @@ func (s *Server) handleGetMessages(w http.ResponseWriter, r *http.Request) { }) } +func writeSessionLoadError(w http.ResponseWriter, id string, err error) { + if errors.Is(err, session.ErrNotFound) { + writeJSON(w, http.StatusNotFound, ErrorResponse{ + Error: "session not found", + Code: "not_found", + }) + return + } + slog.Error("load persisted session failed", "err", err, "session_id", id) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{ + Error: "session load failed", + Code: "session_load_failed", + }) +} + // handleDeleteSession handles DELETE /v1/sessions/{id} — delete a session. func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") @@ -136,17 +160,18 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { return } - // Validate session ID contains only safe characters (alphanumeric, dash, underscore, dot). - for _, c := range id { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { - writeJSON(w, http.StatusBadRequest, ErrorResponse{ - Error: "invalid session id: only alphanumeric, dash, underscore, and dot are allowed", - Code: "invalid_id", - }) - return - } + if !validSessionID(id) { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: "invalid session id: use 1-128 alphanumeric, dash, underscore, or dot characters", + Code: "invalid_id", + }) + return } + lock := s.sessionLock(id) + lock.Lock() + defer lock.Unlock() + sessionsDir := storage.SessionsDir() jsonlPath := filepath.Join(sessionsDir, id+".jsonl") jsonPath := filepath.Join(sessionsDir, id+".json") @@ -155,9 +180,17 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { removedAny := false if err := os.Remove(jsonlPath); err == nil { removedAny = true + } else if !errors.Is(err, os.ErrNotExist) { + slog.Error("delete persisted session failed", "err", err, "session_id", id, "format", "jsonl") + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "session deletion failed", Code: "session_delete_failed"}) + return } if err := os.Remove(jsonPath); err == nil { removedAny = true + } else if !errors.Is(err, os.ErrNotExist) { + slog.Error("delete persisted session failed", "err", err, "session_id", id, "format", "json") + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "session deletion failed", Code: "session_delete_failed"}) + return } if !removedAny { diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index a2ec6a5e..f225cb8c 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -41,7 +41,6 @@ func (s *Session) spawnSubAgent(ctx context.Context, prompt string, mode SubAgen } sub := s.SubSession(model, subSystemPrompt, registry) - sub.SetAPIKeys(s.apiKeys) sub.PermissionFn = s.PermissionFn sub.Permissions = s.Permissions // A sub-agent spawned while the parent is mid-spec-and-unapproved diff --git a/internal/engine/architect.go b/internal/engine/architect.go index 5e161127..544dfdec 100644 --- a/internal/engine/architect.go +++ b/internal/engine/architect.go @@ -6,8 +6,6 @@ import ( "strings" "github.com/GrayCodeAI/hawk/internal/provider/routing" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" ) // ArchitectConfig configures the two-model architect/editor pipeline. @@ -88,7 +86,7 @@ func (a *Architect) Plan(ctx context.Context, goal string, repoContext string) ( if info, ok := routing.Find(a.Config.EditorModel); ok && info.Provider != "" { provider = info.Provider } - model = routing.PreferredModelForTier(provider, eycatalog.TierHaiku, "") + model = routing.PreferredModelForTier(provider, routing.TierHaiku, "") } response, err := a.ChatFn(ctx, model, messages) diff --git a/internal/engine/branching/cascade.go b/internal/engine/branching/cascade.go index 45422b2b..d6e5c0fd 100644 --- a/internal/engine/branching/cascade.go +++ b/internal/engine/branching/cascade.go @@ -8,8 +8,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/engine/cost" "github.com/GrayCodeAI/hawk/internal/provider/routing" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" ) // CascadeRouter selects the optimal model for each request based on task complexity. @@ -218,12 +216,12 @@ func (cr *CascadeRouter) modelForTask(taskType string, frugal bool) string { tier := routing.SuggestTierForTask(taskType) switch tier { - case eycatalog.TierHaiku: + case routing.TierHaiku: if m := cr.Roles.Commit; m != "" { return m } return cr.defaultFor(TierCheap) - case eycatalog.TierSonnet: + case routing.TierSonnet: if frugal { if taskType == "chat" || taskType == "review" { if m := cr.Roles.Commit; m != "" { @@ -236,7 +234,7 @@ func (cr *CascadeRouter) modelForTask(taskType string, frugal bool) string { return m } return cr.defaultFor(TierMid) - case eycatalog.TierOpus: + case routing.TierOpus: if frugal { if m := cr.Roles.Coder; m != "" { return m @@ -264,7 +262,7 @@ func (cr *CascadeRouter) defaultFor(tier ModelTier) string { case TierExpensive: return routing.MostExpensiveForProvider(provider, cr.pick("")) default: - return routing.PreferredModelForTier(provider, eycatalog.TierSonnet, cr.pick("")) + return routing.PreferredModelForTier(provider, routing.TierSonnet, cr.pick("")) } } diff --git a/internal/engine/branching/cascade_test.go b/internal/engine/branching/cascade_test.go index 152fcabc..00e0832c 100644 --- a/internal/engine/branching/cascade_test.go +++ b/internal/engine/branching/cascade_test.go @@ -4,8 +4,6 @@ import ( "testing" "github.com/GrayCodeAI/hawk/internal/provider/routing" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" ) const testProvider = "anthropic" @@ -13,9 +11,9 @@ const testProvider = "anthropic" // testTierModels loads haiku/sonnet/opus model IDs from eyrie's catalog (not hardcoded). func testTierModels(t *testing.T, provider string) (haiku, sonnet, opus string) { t.Helper() - haiku = routing.PreferredModelForTier(provider, eycatalog.TierHaiku, "") - sonnet = routing.PreferredModelForTier(provider, eycatalog.TierSonnet, "") - opus = routing.PreferredModelForTier(provider, eycatalog.TierOpus, "") + haiku = routing.PreferredModelForTier(provider, routing.TierHaiku, "") + sonnet = routing.PreferredModelForTier(provider, routing.TierSonnet, "") + opus = routing.PreferredModelForTier(provider, routing.TierOpus, "") if haiku == "" || sonnet == "" || opus == "" { t.Skipf("no tier models for %q without live catalog (fully dynamic)", provider) } diff --git a/internal/engine/chat_provider.go b/internal/engine/chat_provider.go new file mode 100644 index 00000000..9614d810 --- /dev/null +++ b/internal/engine/chat_provider.go @@ -0,0 +1,68 @@ +package engine + +import ( + "context" + "fmt" + "strings" + + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// BuildChatProvider adapts Hawk's engine-backed session client to the smaller +// provider contract used by host integrations such as Sight. Model resolution, +// credentials, routing, and transport remain owned by Eyrie's engine facade. +func BuildChatProvider(ctx context.Context, selection eyrieengine.Selection, legacyProvider string) (types.ChatProvider, string, error) { + client, provider, _, err := BuildChatClient(ctx, selection, legacyProvider) + if err != nil { + return nil, provider, err + } + provider = strings.TrimSpace(provider) + if provider == "" { + return nil, "", fmt.Errorf("eyrie transport: provider unavailable") + } + return &engineChatProvider{ + client: client, + provider: provider, + model: strings.TrimSpace(selection.Model), + }, provider, nil +} + +// engineChatProvider keeps compatibility-only provider behavior at Hawk's +// integration edge while all generation goes through the engine ChatClient. +type engineChatProvider struct { + client ChatClient + provider string + model string +} + +var _ types.ChatProvider = (*engineChatProvider)(nil) + +func (p *engineChatProvider) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + if strings.TrimSpace(opts.Provider) == "" { + opts.Provider = p.provider + } + if strings.TrimSpace(opts.Model) == "" { + opts.Model = p.model + } + return p.client.Chat(ctx, messages, opts) +} + +func (p *engineChatProvider) StreamChat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.StreamResult, error) { + if strings.TrimSpace(opts.Provider) == "" { + opts.Provider = p.provider + } + if strings.TrimSpace(opts.Model) == "" { + opts.Model = p.model + } + return p.client.StreamChatContinue(ctx, messages, opts, types.ContinuationConfig{}) +} + +func (p *engineChatProvider) Ping(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} + +func (p *engineChatProvider) Name() string { return p.provider } diff --git a/internal/engine/chat_provider_test.go b/internal/engine/chat_provider_test.go new file mode 100644 index 00000000..0904d36a --- /dev/null +++ b/internal/engine/chat_provider_test.go @@ -0,0 +1,73 @@ +package engine + +import ( + "context" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +type recordingChatClient struct { + chatOptions types.ChatOptions + streamOptions types.ChatOptions +} + +func (c *recordingChatClient) Chat(_ context.Context, _ []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + c.chatOptions = opts + return &types.EyrieResponse{Content: "ok"}, nil +} + +func (c *recordingChatClient) StreamChatContinue(_ context.Context, _ []types.EyrieMessage, opts types.ChatOptions, _ types.ContinuationConfig) (*types.StreamResult, error) { + c.streamOptions = opts + events := make(chan types.EyrieStreamEvent) + close(events) + return types.NewStreamResult(events, "", nil), nil +} + +func TestEngineChatProviderAppliesResolvedSelection(t *testing.T) { + client := &recordingChatClient{} + provider := &engineChatProvider{client: client, provider: "anthropic", model: "claude-test"} + + if _, err := provider.Chat(context.Background(), nil, types.ChatOptions{}); err != nil { + t.Fatalf("Chat() error = %v", err) + } + if client.chatOptions.Provider != "anthropic" || client.chatOptions.Model != "claude-test" { + t.Fatalf("Chat() options = %#v, want resolved provider and model", client.chatOptions) + } + + if _, err := provider.StreamChat(context.Background(), nil, types.ChatOptions{}); err != nil { + t.Fatalf("StreamChat() error = %v", err) + } + if client.streamOptions.Provider != "anthropic" || client.streamOptions.Model != "claude-test" { + t.Fatalf("StreamChat() options = %#v, want resolved provider and model", client.streamOptions) + } +} + +func TestEngineChatProviderPreservesRequestOverrides(t *testing.T) { + client := &recordingChatClient{} + provider := &engineChatProvider{client: client, provider: "anthropic", model: "claude-test"} + + opts := types.ChatOptions{Provider: "openai", Model: "gpt-test"} + if _, err := provider.Chat(context.Background(), nil, opts); err != nil { + t.Fatalf("Chat() error = %v", err) + } + if client.chatOptions.Provider != "openai" || client.chatOptions.Model != "gpt-test" { + t.Fatalf("Chat() options = %#v, want request overrides", client.chatOptions) + } +} + +func TestEngineChatProviderIdentityAndPing(t *testing.T) { + provider := &engineChatProvider{client: &recordingChatClient{}, provider: "openai"} + if got := provider.Name(); got != "openai" { + t.Fatalf("Name() = %q, want openai", got) + } + if err := provider.Ping(context.Background()); err != nil { + t.Fatalf("Ping() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := provider.Ping(ctx); err != context.Canceled { + t.Fatalf("Ping() error = %v, want context.Canceled", err) + } +} diff --git a/internal/engine/chat_service.go b/internal/engine/chat_service.go index a04cfc60..75e342c9 100644 --- a/internal/engine/chat_service.go +++ b/internal/engine/chat_service.go @@ -8,14 +8,12 @@ import ( "github.com/GrayCodeAI/hawk/internal/resilience/ratelimit" "github.com/GrayCodeAI/hawk/internal/resilience/retry" "github.com/GrayCodeAI/hawk/internal/types" - - modelPkg "github.com/GrayCodeAI/hawk/internal/provider/routing" ) // ChatService is the Session's view of the LLM transport. It owns the -// eyrie client, the provider/model identity, API keys, the circuit-breaker -// router, the rate limiter, and the streaming-with-continuation retry -// logic. It is constructed once in NewSessionWithClient and consulted by +// Eyrie client, provider/model identity, and compatibility-only rate/retry +// controls. Production facade clients own resilience inside Eyrie. The service +// is constructed once in NewSessionWithClient and consulted by // agentLoop every turn. // // Extracted from Session in the god-object decomposition. Session now @@ -28,12 +26,6 @@ type ChatService struct { // provider / model are the active LLM identity. provider string model string - // apiKeys is provider→key, used for legacy single-provider clients. - apiKeys map[string]string - // router is the legacy single-provider circuit breaker. Bypassed - // when DeploymentRouting is true (the DeploymentRouter has its own - // per-deployment breakers). - router *modelPkg.Router // deploymentRouting is true when the client is catalog-backed // (e.g. DeploymentRouter from eyrie/runtime.ChatProvider). deploymentRouting bool @@ -59,8 +51,6 @@ type ChatService struct { type ChatServiceConfig struct { Provider string Model string - APIKeys map[string]string - Router *modelPkg.Router DeploymentRouting bool RateLimiter *ratelimit.Limiter Metrics *metrics.Registry @@ -73,9 +63,6 @@ type ChatServiceConfig struct { // NewChatService constructs a ChatService with sensible defaults for any // zero-valued field in cfg. The client must be non-nil. func NewChatService(client ChatClient, cfg ChatServiceConfig) *ChatService { - if cfg.APIKeys == nil { - cfg.APIKeys = map[string]string{} - } if cfg.RetryConfig.MaxRetries == 0 { cfg.RetryConfig = retry.DefaultConfig() cfg.RetryConfig.MaxRetries = 2 @@ -91,8 +78,6 @@ func NewChatService(client ChatClient, cfg ChatServiceConfig) *ChatService { client: client, provider: cfg.Provider, model: cfg.Model, - apiKeys: cfg.APIKeys, - router: cfg.Router, deploymentRouting: cfg.DeploymentRouting, rateLimiter: cfg.RateLimiter, metrics: cfg.Metrics, @@ -114,19 +99,10 @@ func (c *ChatService) Provider() string { return c.provider } // Model returns the active model identifier. func (c *ChatService) Model() string { return c.model } -// APIKeys returns the provider→key map. Used by Session.SubSession to -// clone credentials for sub-agents. -func (c *ChatService) APIKeys() map[string]string { return c.apiKeys } - // DeploymentRouting reports whether the underlying client is catalog-backed // (true) or a single-provider transport (false). func (c *ChatService) DeploymentRouting() bool { return c.deploymentRouting } -// SetAPIKey stores a provider→key mapping. -func (c *ChatService) SetAPIKey(provider, key string) { - c.apiKeys[provider] = key -} - // SetModel updates the active model. The next StreamChat will use the new // model. func (c *ChatService) SetModel(model string) { @@ -139,7 +115,7 @@ func (c *ChatService) SetProvider(provider string) { } // Reattach swaps the underlying client (e.g. after deployment routing -// changes). Preserves the APIKeys and other config. +// changes). func (c *ChatService) Reattach(client ChatClient, provider string) { if client == nil { return @@ -183,13 +159,14 @@ func (c *ChatService) BuildOptions(systemPrompt, activeModel string, maxTokens i // 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. +// Eyrie facade clients advertise that they manage provider resilience. For +// those clients this service records the product metric and delegates exactly +// once; injected legacy clients retain Hawk's compatibility retry/rate layer. func (c *ChatService) Stream(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.StreamResult, error) { + if clientManagesResilience(c.client) { + c.metrics.Counter("api.requests").Inc() + return c.client.StreamChatContinue(ctx, messages, opts, c.contCfg) + } // Rate limit: wait for a token before making the LLM call if c.rateLimiter != nil { if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { @@ -248,10 +225,6 @@ func isZAIProvider(provider string) bool { } } -// Router returns the legacy single-provider circuit breaker. New -// code should access this through s.ChatLLM().Router(). -func (c *ChatService) Router() *modelPkg.Router { return c.router } - func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { diff --git a/internal/engine/chat_service_test.go b/internal/engine/chat_service_test.go index d3b47be8..472abaee 100644 --- a/internal/engine/chat_service_test.go +++ b/internal/engine/chat_service_test.go @@ -4,7 +4,9 @@ import ( "context" "errors" "testing" + "time" + "github.com/GrayCodeAI/hawk/internal/resilience/retry" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -72,17 +74,10 @@ func TestChatService_BuildOptions_OutputSchema(t *testing.T) { } } -func TestChatService_Reattach_PreservesKeys(t *testing.T) { +func TestChatService_Reattach(t *testing.T) { oldClient := NewMockClientForTest() newClient := NewMockClientForTest() - svc := NewChatService(oldClient, ChatServiceConfig{ - Provider: "anthropic", - Model: "claude-opus-4", - APIKeys: map[string]string{"anthropic": "sk-test"}, - }) - if got := svc.APIKeys()["anthropic"]; got != "sk-test" { - t.Fatalf("expected key sk-test, got %q", got) - } + svc := NewChatService(oldClient, ChatServiceConfig{Provider: "anthropic", Model: "claude-opus-4"}) // Reattach with a nil client should be a no-op (preserve current). svc.Reattach(nil, "") if svc.Client() != oldClient { @@ -93,9 +88,6 @@ func TestChatService_Reattach_PreservesKeys(t *testing.T) { if svc.Provider() != "openai" { t.Errorf("expected provider=openai, got %q", svc.Provider()) } - if got := svc.APIKeys()["anthropic"]; got != "sk-test" { - t.Errorf("Reattach should preserve API keys, got %q", got) - } } func TestChatService_DefaultsApplied(t *testing.T) { @@ -110,9 +102,6 @@ func TestChatService_DefaultsApplied(t *testing.T) { if svc.metrics == nil { t.Error("expected default metrics registry") } - if svc.apiKeys == nil { - t.Error("expected apiKeys to be initialized to empty map (so callers can SetAPIKey without nil check)") - } } func TestChatService_ChatDelegatesToClient(t *testing.T) { @@ -144,7 +133,6 @@ func (e *errClient) Chat(_ context.Context, _ []types.EyrieMessage, _ types.Chat func (e *errClient) StreamChatContinue(_ context.Context, _ []types.EyrieMessage, _ types.ChatOptions, _ types.ContinuationConfig) (*types.StreamResult, error) { return nil, e.err } -func (e *errClient) SetAPIKey(_ string, _ string) {} func TestChatService_ChatSurfacesError(t *testing.T) { want := errors.New("upstream kaput") @@ -154,3 +142,72 @@ func TestChatService_ChatSurfacesError(t *testing.T) { t.Errorf("expected err %v, got %v", want, err) } } + +type resilienceManagingTestClient struct { + err error + calls int +} + +func (c *resilienceManagingTestClient) Chat(context.Context, []types.EyrieMessage, types.ChatOptions) (*types.EyrieResponse, error) { + return nil, c.err +} + +func (c *resilienceManagingTestClient) StreamChatContinue(context.Context, []types.EyrieMessage, types.ChatOptions, types.ContinuationConfig) (*types.StreamResult, error) { + c.calls++ + return nil, c.err +} +func (c *resilienceManagingTestClient) ManagesResilience() bool { return true } + +func TestChatService_DoesNotDuplicateEngineResilience(t *testing.T) { + client := &resilienceManagingTestClient{err: errors.New("routed transport failed")} + svc := NewChatService(client, ChatServiceConfig{}) + _, err := svc.Stream(context.Background(), []types.EyrieMessage{{Role: "user", Content: "hi"}}, types.ChatOptions{}) + if err == nil { + t.Fatal("expected stream error") + } + if client.calls != 1 { + t.Fatalf("engine-owning client called %d times, want exactly once", client.calls) + } +} + +type flakyLegacyStartClient struct { + calls int +} + +func (*flakyLegacyStartClient) Chat(context.Context, []types.EyrieMessage, types.ChatOptions) (*types.EyrieResponse, error) { + return nil, nil +} + +func (c *flakyLegacyStartClient) StreamChatContinue(context.Context, []types.EyrieMessage, types.ChatOptions, types.ContinuationConfig) (*types.StreamResult, error) { + c.calls++ + if c.calls == 1 { + return nil, errors.New("temporary transport failure") + } + events := make(chan types.EyrieStreamEvent) + close(events) + return types.NewStreamResult(events, "", nil), nil +} + +func TestChatService_LegacyClientRetainsStartRetry(t *testing.T) { + client := &flakyLegacyStartClient{} + svc := NewChatService(client, ChatServiceConfig{RetryConfig: retryConfigForBoundaryTest()}) + result, err := svc.Stream(context.Background(), nil, types.ChatOptions{}) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if result == nil { + t.Fatal("Stream() returned nil result after compatibility retry") + } + if client.calls != 2 { + t.Fatalf("legacy client calls = %d, want initial attempt plus one retry", client.calls) + } +} + +func retryConfigForBoundaryTest() retry.Config { + return retry.Config{ + MaxRetries: 1, + BaseDelay: time.Nanosecond, + MaxDelay: time.Nanosecond, + Multiplier: 1, + } +} diff --git a/internal/engine/client_interface.go b/internal/engine/client_interface.go index 48d67701..2cfa3f36 100644 --- a/internal/engine/client_interface.go +++ b/internal/engine/client_interface.go @@ -7,11 +7,23 @@ import ( ) // ChatClient abstracts the LLM client methods used by Session. -// The production implementation is *types.EyrieClient; tests can inject a mock. +// The production implementation is the Eyrie engine adapter; tests can inject a mock. type ChatClient interface { Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, cfg types.ContinuationConfig) (*types.StreamResult, error) - SetAPIKey(provider, apiKey string) +} + +// resilienceManagingChatClient is an optional capability implemented by +// facade clients that already own provider retries, rate limiting, +// continuation, and protocol normalization. Keeping it separate from +// ChatClient preserves compatibility with injected and legacy clients. +type resilienceManagingChatClient interface { + ManagesResilience() bool +} + +func clientManagesResilience(client ChatClient) bool { + manager, ok := client.(resilienceManagingChatClient) + return ok && manager.ManagesResilience() } // SetTestClient replaces the session's LLM client. For testing only. @@ -46,5 +58,3 @@ func (m *exportedMockClient) StreamChatContinue(ctx context.Context, messages [] close(ch) return &types.StreamResult{Events: ch}, nil } - -func (m *exportedMockClient) SetAPIKey(provider, apiKey string) {} diff --git a/internal/engine/compact_provider_native.go b/internal/engine/compact_provider_native.go index 3dd047ff..134499e1 100644 --- a/internal/engine/compact_provider_native.go +++ b/internal/engine/compact_provider_native.go @@ -1,266 +1,71 @@ package engine import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" - "strings" - "time" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" "github.com/GrayCodeAI/hawk/internal/types" ) -const ( - anthropicCompactBeta = "compact-2026-01-12" - anthropicCompactEdit = "compact_20260112" - anthropicMinCompactTrig = 50_000 -) - -// ProviderNativeCompactStrategy uses Anthropic server-side compaction when available. +// ProviderNativeCompactStrategy delegates provider-specific compaction to +// Eyrie. Hawk owns when conversation state is compacted and how the resulting +// summary is inserted; Eyrie owns credentials and provider transport details. type ProviderNativeCompactStrategy struct{} func (s *ProviderNativeCompactStrategy) Name() string { return "provider_native" } func (s *ProviderNativeCompactStrategy) ShouldTrigger(msgs []types.EyrieMessage, tokenCount, threshold int) bool { - if tokenCount < threshold || len(msgs) < 8 { - return false - } - return true + return tokenCount >= threshold && len(msgs) >= 8 } func (s *ProviderNativeCompactStrategy) Compact(ctx context.Context, sess *Session) (*CompactResult, error) { if sess == nil { return nil, fmt.Errorf("no session") } - if !sess.supportsAnthropicNativeCompaction() { + client, ok := sess.engineFacadeClient() + if !ok || !client.engine.SupportsNativeCompaction(ctx, sess.provider, sess.model) { return nil, fmt.Errorf("provider native compaction not available") } - tokensBefore := EstimateTokens(sess.messages) - result, err := anthropicNativeCompact(ctx, sess, tokensBefore) - if err != nil { - return nil, err - } - sess.messages = result.Messages - return result, nil -} - -func (s *Session) supportsAnthropicNativeCompaction() bool { - if s == nil { - return false - } - key := s.anthropicAPIKey() - if key == "" { - return false - } - prov := strings.ToLower(strings.TrimSpace(s.provider)) - if prov != "anthropic" && !strings.Contains(prov, "anthropic") { - // Allow claude models routed via compatible gateways when direct key exists. - model := strings.ToLower(strings.TrimSpace(s.model)) - if !strings.HasPrefix(model, "claude-") { - return false - } - } - return anthropicCompactionModel(s.model) -} - -func (s *Session) anthropicAPIKey() string { - if s == nil { - return "" - } - s.mu.RLock() - defer s.mu.RUnlock() - if k := strings.TrimSpace(s.apiKeys["anthropic"]); k != "" { - return k - } - return "" -} - -func anthropicCompactionModel(model string) bool { - m := strings.ToLower(strings.TrimSpace(model)) - if m == "" { - return false - } - // Supported families per Anthropic compaction docs. - prefixes := []string{ - "claude-opus-4", "claude-sonnet-4", "claude-mythos", - } - for _, p := range prefixes { - if strings.HasPrefix(m, p) { - return true - } - } - return false -} - -func anthropicNativeCompact(ctx context.Context, sess *Session, tokensBefore int) (*CompactResult, error) { - key := sess.anthropicAPIKey() - trigger := sess.ContextWindowSize() * sess.compactThresholdPct() / 100 - if trigger < anthropicMinCompactTrig { - trigger = anthropicMinCompactTrig - } - - msgs, system := buildAnthropicPayloadMessages(sess.messages) - body := map[string]interface{}{ - "model": sess.model, - "max_tokens": 8192, - "messages": msgs, - "context_management": map[string]interface{}{ - "edits": []map[string]interface{}{ - { - "type": anthropicCompactEdit, - "trigger": map[string]interface{}{ - "type": "input_tokens", - "value": trigger, - }, - "pause_after_compaction": true, - }, - }, - }, - } - if system != "" { - body["system"] = system - } - raw, err := json.Marshal(body) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(raw)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Api-Key", key) - req.Header.Set("Anthropic-Version", "2023-06-01") - req.Header.Set("Anthropic-Beta", anthropicCompactBeta) - client := &http.Client{Timeout: 120 * time.Second} - resp, err := client.Do(req) + tokensBefore := EstimateTokens(sess.messages) + summary, err := client.engine.CompactNative(ctx, eyrieengine.NativeCompactionRequest{ + Provider: sess.provider, + Model: sess.model, + Messages: toEngineMessages(sess.messages), + ContextWindow: sess.ContextWindowSize(), + ThresholdPct: sess.compactThresholdPct(), + MaxOutputTokens: 8192, + }) if err != nil { return nil, err } - defer func() { _ = resp.Body.Close() }() - respBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("anthropic compaction HTTP %d: %s", resp.StatusCode, truncateErrBody(respBody)) - } - - var parsed anthropicCompactResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - return nil, fmt.Errorf("parse compaction response: %w", err) - } - summary := parsed.compactionSummary() - if summary == "" { - return nil, fmt.Errorf("anthropic compaction produced no summary (stop=%s)", parsed.StopReason) - } keepEnd := 6 if keepEnd > len(sess.messages) { keepEnd = len(sess.messages) } tail := append([]types.EyrieMessage(nil), sess.messages[len(sess.messages)-keepEnd:]...) - compactMsg := types.EyrieMessage{ - Role: "user", - Content: FormatCompactSummary(summary), - } - newMsgs := append([]types.EyrieMessage{compactMsg}, tail...) - tokensAfter := EstimateTokens(newMsgs) - - return &CompactResult{ - Messages: newMsgs, + messages := append([]types.EyrieMessage{{Role: "user", Content: FormatCompactSummary(summary)}}, tail...) + compact := &CompactResult{ + Messages: messages, TokensBefore: tokensBefore, - TokensAfter: tokensAfter, + TokensAfter: EstimateTokens(messages), Strategy: "provider_native", - }, nil -} - -type anthropicCompactResponse struct { - StopReason string `json:"stop_reason"` - Content []struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - // compaction block may nest summary in text or dedicated fields - Summary string `json:"summary,omitempty"` - } `json:"content"` -} - -func (r *anthropicCompactResponse) compactionSummary() string { - for _, block := range r.Content { - switch block.Type { - case "compaction", "text": - if s := strings.TrimSpace(block.Summary); s != "" { - return s - } - if s := strings.TrimSpace(block.Text); s != "" { - return s - } - } } - return "" + sess.messages = compact.Messages + return compact, nil } -func buildAnthropicPayloadMessages(messages []types.EyrieMessage) ([]map[string]interface{}, string) { - var system string - out := make([]map[string]interface{}, 0, len(messages)) - for _, m := range messages { - if m.Role == "system" { - system = m.Content - continue - } - if m.Role == "assistant" && len(m.ToolUse) > 0 { - content := make([]map[string]interface{}, 0) - if m.Content != "" { - content = append(content, map[string]interface{}{"type": "text", "text": m.Content}) - } - for _, tc := range m.ToolUse { - input := tc.Arguments - if input == nil { - input = map[string]interface{}{} - } - content = append(content, map[string]interface{}{ - "type": "tool_use", - "id": tc.ID, - "name": tc.Name, - "input": input, - }) - } - out = append(out, map[string]interface{}{"role": "assistant", "content": content}) - continue - } - if m.Role == "user" && len(m.ToolResults) > 0 { - content := make([]map[string]interface{}, 0) - if m.Content != "" { - content = append(content, map[string]interface{}{"type": "text", "text": m.Content}) - } - for _, tr := range m.ToolResults { - block := map[string]interface{}{ - "type": "tool_result", - "tool_use_id": tr.ToolUseID, - "content": tr.Content, - } - if tr.IsError { - block["is_error"] = true - } - content = append(content, block) - } - out = append(out, map[string]interface{}{"role": "user", "content": content}) - continue - } - out = append(out, map[string]interface{}{ - "role": m.Role, - "content": m.Content, - }) +func (s *Session) engineFacadeClient() (*eyrieEngineClient, bool) { + if s == nil || s.ChatLLM() == nil { + return nil, false } - return out, system + client, ok := s.ChatLLM().Client().(*eyrieEngineClient) + return client, ok && client != nil && client.engine != nil } -func truncateErrBody(b []byte) string { - s := strings.TrimSpace(string(b)) - if len(s) > 200 { - return s[:200] + "..." - } - return s +func (s *Session) supportsAnthropicNativeCompaction() bool { + client, ok := s.engineFacadeClient() + return ok && client.engine.SupportsNativeCompaction(context.Background(), s.provider, s.model) } diff --git a/internal/engine/compact_provider_native_test.go b/internal/engine/compact_provider_native_test.go deleted file mode 100644 index 5f6985be..00000000 --- a/internal/engine/compact_provider_native_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package engine - -import "testing" - -func TestAnthropicCompactionModel(t *testing.T) { - cases := map[string]bool{ - "claude-sonnet-4-6": true, - "claude-opus-4-8": true, - "claude-mythos-preview": true, - "gpt-4o": false, - "": false, - } - for model, want := range cases { - if got := anthropicCompactionModel(model); got != want { - t.Errorf("anthropicCompactionModel(%q) = %v, want %v", model, got, want) - } - } -} diff --git a/internal/engine/compact_strategy_test.go b/internal/engine/compact_strategy_test.go index 8298b77f..23148033 100644 --- a/internal/engine/compact_strategy_test.go +++ b/internal/engine/compact_strategy_test.go @@ -60,7 +60,7 @@ func TestTruncateStrategy(t *testing.T) { messages: makeMessages(100), log: newTestLogger(), metrics: newTestMetrics(), - client: types.NewClient(&types.ClientConfig{Provider: "test"}), + client: NewMockClientForTest(), } s := &TruncateStrategy{} diff --git a/internal/engine/context_compaction_test.go b/internal/engine/context_compaction_test.go index deb291ef..2b7532c3 100644 --- a/internal/engine/context_compaction_test.go +++ b/internal/engine/context_compaction_test.go @@ -1,7 +1,11 @@ package engine import ( + "context" "testing" + + "github.com/GrayCodeAI/eyrie/credentials" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) func TestContextUsedTokens_PrefersAPI(t *testing.T) { @@ -16,17 +20,21 @@ func TestContextUsedTokens_PrefersAPI(t *testing.T) { } } -func TestSupportsAnthropicNativeCompaction_Model(t *testing.T) { - s := NewSession("", "claude-sonnet-4-6", "sys", nil) - s.mu.Lock() - s.apiKeys["anthropic"] = "sk-test" - s.provider = "anthropic" - s.mu.Unlock() - if !s.supportsAnthropicNativeCompaction() { - t.Fatal("expected anthropic native compaction support") +func TestNativeCompactionSupportUsesEyrieCredentialStore(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + runtime, err := eyrieengine.New(eyrieengine.Options{SecretStore: store}) + if err != nil { + t.Fatal(err) } - s.SetModel("gpt-4o") + s := NewSessionWithClient(newEyrieEngineClient(runtime), "anthropic", "claude-sonnet-4-6", "sys", nil, true) if s.supportsAnthropicNativeCompaction() { - t.Fatal("expected no support for non-claude model") + t.Fatal("expected no support before Eyrie has a credential") + } + if err := store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-test"); err != nil { + t.Fatal(err) + } + if !s.supportsAnthropicNativeCompaction() { + t.Fatal("expected support from Eyrie's injected credential store") } } diff --git a/internal/engine/cost/cost.go b/internal/engine/cost/cost.go index 85d07b21..279a854a 100644 --- a/internal/engine/cost/cost.go +++ b/internal/engine/cost/cost.go @@ -2,6 +2,7 @@ package cost import ( "fmt" + "strings" "sync" ) @@ -18,6 +19,22 @@ type Cost struct { func (c *Cost) Add(prompt, completion int) { c.mu.Lock() defer c.mu.Unlock() + c.addLocked(prompt, completion) +} + +// AddForModel records usage against the concrete model selected by the provider +// engine. The model update and price lookup are atomic with the token/cost +// update, so a routed fallback cannot be billed using the requested model. +func (c *Cost) AddForModel(model string, prompt, completion int) { + c.mu.Lock() + defer c.mu.Unlock() + if model = strings.TrimSpace(model); model != "" { + c.Model = model + } + c.addLocked(prompt, completion) +} + +func (c *Cost) addLocked(prompt, completion int) { c.PromptTokens += prompt c.CompletionTokens += completion inPrice, outPrice := ModelPricing(c.Model) diff --git a/internal/engine/cost/cost_optimizer_test.go b/internal/engine/cost/cost_optimizer_test.go index a324b7bd..f0e9927c 100644 --- a/internal/engine/cost/cost_optimizer_test.go +++ b/internal/engine/cost/cost_optimizer_test.go @@ -5,7 +5,6 @@ import ( "testing" "time" - eycatalog "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/hawk/internal/provider/routing" ) @@ -13,9 +12,9 @@ const testProvider = "anthropic" func testTierModels(t *testing.T, provider string) (haiku, sonnet, opus string) { t.Helper() - haiku = routing.PreferredModelForTier(provider, eycatalog.TierHaiku, "") - sonnet = routing.PreferredModelForTier(provider, eycatalog.TierSonnet, "") - opus = routing.PreferredModelForTier(provider, eycatalog.TierOpus, "") + haiku = routing.PreferredModelForTier(provider, routing.TierHaiku, "") + sonnet = routing.PreferredModelForTier(provider, routing.TierSonnet, "") + opus = routing.PreferredModelForTier(provider, routing.TierOpus, "") if haiku == "" || sonnet == "" || opus == "" { t.Skipf("no tier models for %q without live catalog (fully dynamic)", provider) } diff --git a/internal/engine/engine_stage2_test_helpers.go b/internal/engine/engine_stage2_test_helpers.go index 32fe01a4..00ed9280 100644 --- a/internal/engine/engine_stage2_test_helpers.go +++ b/internal/engine/engine_stage2_test_helpers.go @@ -3,7 +3,6 @@ package engine import ( "testing" - "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/hawk/internal/provider/routing" ) @@ -11,9 +10,9 @@ const testProvider = "anthropic" func testTierModels(t *testing.T, provider string) (haiku, sonnet, opus string) { t.Helper() - haiku = routing.PreferredModelForTier(provider, catalog.TierHaiku, "") - sonnet = routing.PreferredModelForTier(provider, catalog.TierSonnet, "") - opus = routing.PreferredModelForTier(provider, catalog.TierOpus, "") + haiku = routing.PreferredModelForTier(provider, routing.TierHaiku, "") + sonnet = routing.PreferredModelForTier(provider, routing.TierSonnet, "") + opus = routing.PreferredModelForTier(provider, routing.TierOpus, "") if haiku == "" || sonnet == "" || opus == "" { t.Skipf("no tier models for %q without live catalog (fully dynamic)", provider) } diff --git a/internal/engine/eyrie_engine_client.go b/internal/engine/eyrie_engine_client.go new file mode 100644 index 00000000..e316356f --- /dev/null +++ b/internal/engine/eyrie_engine_client.go @@ -0,0 +1,278 @@ +package engine + +import ( + "context" + + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// eyrieEngineClient is Hawk's anti-corruption adapter from the product-owned +// ChatClient port to Eyrie's stable engine facade. Hawk continues to own its +// agent loop and conversation state; Eyrie owns model resolution and transport. +type eyrieEngineClient struct { + engine *eyrieengine.Engine +} + +var _ resilienceManagingChatClient = (*eyrieEngineClient)(nil) + +func newEyrieEngineClient(runtime *eyrieengine.Engine) ChatClient { + return &eyrieEngineClient{engine: runtime} +} + +func (c *eyrieEngineClient) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + response, err := c.engine.Generate(ctx, toEngineRequest(messages, opts, types.ContinuationConfig{})) + if err != nil { + return nil, err + } + return fromEngineResponse(response), nil +} + +func (c *eyrieEngineClient) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, continuation types.ContinuationConfig) (*types.StreamResult, error) { + request := toEngineRequest(messages, opts, continuation) + request.Requirements.Streaming = true + stream, err := c.engine.Stream(ctx, request) + if err != nil { + return nil, err + } + events := make(chan types.EyrieStreamEvent, 64) + streamCtx, cancel := context.WithCancel(ctx) + go func() { + defer close(events) + defer func() { _ = stream.Close() }() + for stream.Next() { + event, emit := fromEngineEvent(stream.Event()) + if !emit { + continue + } + select { + case events <- event: + case <-streamCtx.Done(): + return + } + } + if err := stream.Err(); err != nil { + select { + case events <- types.EyrieStreamEvent{Type: "error", Error: err.Error()}: + case <-streamCtx.Done(): + } + } + }() + closeFn := func() { + cancel() + _ = stream.Close() + } + return types.NewStreamResult(events, "", closeFn), nil +} + +// ManagesResilience tells Hawk not to add provider retry, continuation, or +// protocol-recovery layers around Eyrie's routed transport. +func (c *eyrieEngineClient) ManagesResilience() bool { return true } + +func toEngineRequest(messages []types.EyrieMessage, opts types.ChatOptions, continuation types.ContinuationConfig) eyrieengine.GenerateRequest { + glmReasoningEnabled := opts.GLMThinkingEnabled != nil && *opts.GLMThinkingEnabled + request := eyrieengine.GenerateRequest{ + Messages: toEngineMessages(messages), + SystemPrompt: opts.System, + Tools: toEngineTools(opts.Tools), + Requirements: eyrieengine.Requirements{ + Streaming: opts.Stream, + Tools: len(opts.Tools) > 0, + Vision: messagesContainVision(messages), + StructuredJSON: opts.ResponseFormat != nil || opts.OutputSchema != "", + Reasoning: opts.ReasoningEffort != "" || opts.ThinkingBudgetTokens > 0 || opts.ThinkingMode != "" || glmReasoningEnabled, + }, + Preference: eyrieengine.Preference{ + PreferredProvider: opts.Provider, + PreferredModelID: opts.Model, + }, + Limits: eyrieengine.Limits{ + MaxOutputTokens: opts.MaxTokens, + MaxContinuations: continuation.MaxContinuations, + MaxTotalOutputTokens: continuation.MaxTotalTokens, + }, + Metadata: eyrieengine.Metadata{UserID: opts.MetadataUserID}, + Temperature: opts.Temperature, + OutputSchema: firstNonEmpty(opts.OutputSchema, responseSchema(opts.ResponseFormat)), + Options: eyrieengine.GenerationOptions{ + EnableCaching: opts.EnableCaching, ReasoningEffort: opts.ReasoningEffort, + ThinkingBudgetTokens: opts.ThinkingBudgetTokens, ThinkingMode: opts.ThinkingMode, + ThinkingDisplay: opts.ThinkingDisplay, GLMThinkingEnabled: opts.GLMThinkingEnabled, + VirtualKeyID: opts.VirtualKeyID, KimiContextCacheID: opts.KimiContextCacheID, + KimiCacheResetTTL: opts.KimiCacheResetTTL, TopP: opts.TopP, TopK: opts.TopK, + StopSequences: append([]string(nil), opts.StopSequences...), ToolChoice: toEngineToolChoice(opts.ToolChoice), + ServiceTier: opts.ServiceTier, OutputEffort: opts.OutputEffort, + PresencePenalty: opts.PresencePenalty, FrequencyPenalty: opts.FrequencyPenalty, + N: opts.N, LogProbs: opts.LogProbs, TopLogProbs: opts.TopLogProbs, Seed: opts.Seed, + Store: opts.Store, Metadata: cloneMetadata(opts.Metadata), Modalities: append([]string(nil), opts.Modalities...), + AudioConfig: opts.AudioConfig, Prediction: opts.Prediction, WebSearchOptions: opts.WebSearchOptions, + }, + } + return request +} + +func toEngineMessages(messages []types.EyrieMessage) []eyrieengine.Message { + out := make([]eyrieengine.Message, 0, len(messages)) + for _, message := range messages { + parts := make([]eyrieengine.ContentPart, 0, len(message.ContentParts)+len(message.Images)) + for _, part := range message.ContentParts { + converted := eyrieengine.ContentPart{Type: part.Type, Text: part.Text} + if part.ImageURL != nil { + converted.URL, converted.Detail = part.ImageURL.URL, part.ImageURL.Detail + } + if part.InputAudio != nil { + converted.AudioData, converted.AudioFormat = part.InputAudio.Data, part.InputAudio.Format + } + parts = append(parts, converted) + } + for _, image := range message.Images { + parts = append(parts, eyrieengine.ContentPart{Type: "image_url", URL: image}) + } + calls := make([]eyrieengine.ToolCall, 0, len(message.ToolUse)) + for _, call := range message.ToolUse { + calls = append(calls, eyrieengine.ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) + } + results := make([]eyrieengine.ToolResult, 0, len(message.ToolResults)) + for _, result := range message.ToolResults { + results = append(results, eyrieengine.ToolResult{ToolUseID: result.ToolUseID, Content: result.Content, IsError: result.IsError}) + } + out = append(out, eyrieengine.Message{ + Role: message.Role, Content: message.Content, Thinking: message.Thinking, + ContentParts: parts, ToolCalls: calls, ToolResults: results, + }) + } + return out +} + +func toEngineTools(tools []types.EyrieTool) []eyrieengine.Tool { + out := make([]eyrieengine.Tool, 0, len(tools)) + for _, tool := range tools { + out = append(out, eyrieengine.Tool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters}) + } + return out +} + +func toEngineToolChoice(choice *types.ToolChoiceOption) *eyrieengine.ToolChoice { + if choice == nil { + return nil + } + return &eyrieengine.ToolChoice{Type: choice.Type, Name: choice.Name, DisableParallelToolUse: choice.DisableParallelToolUse} +} + +func fromEngineResponse(response *eyrieengine.GenerateResponse) *types.EyrieResponse { + if response == nil { + return &types.EyrieResponse{} + } + calls := make([]types.ToolCall, 0, len(response.ToolCalls)) + for _, call := range response.ToolCalls { + calls = append(calls, types.ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) + } + return &types.EyrieResponse{ + Content: response.Content, Thinking: response.Thinking, ToolCalls: calls, + FinishReason: response.FinishReason, RequestID: response.RequestID, Usage: fromEngineUsage(response.Usage), + Route: fromEngineRoute(response.Route), + } +} + +func fromEngineEvent(event eyrieengine.Event) (types.EyrieStreamEvent, bool) { + out := types.EyrieStreamEvent{ + Content: event.Content, Thinking: event.Thinking, RequestID: event.RequestID, + Usage: fromEngineUsage(event.Usage), StopReason: event.StopReason, TTFTms: event.TTFTMillis, + } + switch event.Type { + case eyrieengine.EventRouteSelected: + out.Type = "route_selected" + case eyrieengine.EventRouteChanged: + out.Type = "route_changed" + case eyrieengine.EventContentDelta: + out.Type = "content" + case eyrieengine.EventThinkingDelta: + out.Type = "thinking" + case eyrieengine.EventToolCallStart, eyrieengine.EventToolCallDone: + out.Type = "tool_call" + case eyrieengine.EventToolCallDelta: + out.Type = "tool_input_delta" + case eyrieengine.EventUsage: + out.Type = "usage" + case eyrieengine.EventTTFT: + out.Type = "ttft" + out.TTFT = event.TTFTMillis + case eyrieengine.EventDone: + out.Type = "done" + case eyrieengine.EventContinuation: + out.Type = "continuation" + case eyrieengine.EventWarning: + out.Type, out.Content = "warning", event.Warning + default: + out.Type = string(event.Type) + } + if event.ToolCall != nil { + out.ToolCall = &types.ToolCall{ID: event.ToolCall.ID, Name: event.ToolCall.Name, Arguments: event.ToolCall.Arguments} + } + if event.Route != nil { + out.Route = fromEngineRoute(*event.Route) + } + return out, true +} + +func fromEngineRoute(route eyrieengine.Route) *types.ResolvedRoute { + if route.Provider == "" && route.Model == "" && !route.DeploymentRouting { + return nil + } + return &types.ResolvedRoute{ + Provider: route.Provider, + Model: route.Model, + DeploymentRouting: route.DeploymentRouting, + } +} + +func fromEngineUsage(usage *eyrieengine.Usage) *types.EyrieUsage { + if usage == nil { + return nil + } + return &types.EyrieUsage{ + PromptTokens: usage.InputTokens, CompletionTokens: usage.OutputTokens, TotalTokens: usage.TotalTokens, + CacheCreationTokens: usage.CacheCreationTokens, CacheReadTokens: usage.CacheReadTokens, ThinkingTokens: usage.ThinkingTokens, + } +} + +func responseSchema(format *types.ResponseFormat) string { + if format == nil { + return "" + } + return format.Schema +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func cloneMetadata(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func messagesContainVision(messages []types.EyrieMessage) bool { + for _, message := range messages { + if len(message.Images) > 0 { + return true + } + for _, part := range message.ContentParts { + if part.ImageURL != nil || part.Type == "image_url" { + return true + } + } + } + return false +} diff --git a/internal/engine/eyrie_engine_client_test.go b/internal/engine/eyrie_engine_client_test.go new file mode 100644 index 00000000..c4a7fad7 --- /dev/null +++ b/internal/engine/eyrie_engine_client_test.go @@ -0,0 +1,107 @@ +package engine + +import ( + "testing" + + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestEngineAdapterPreservesHawkRequestOptions(t *testing.T) { + topP := 0.75 + thinking := true + request := toEngineRequest( + []types.EyrieMessage{{Role: "user", Content: "inspect", Images: []string{"data:image/png;base64,abc"}}}, + types.ChatOptions{ + Provider: "openrouter", Model: "openrouter/auto", MaxTokens: 2048, + Tools: []types.EyrieTool{{Name: "read_file", Description: "read", Parameters: map[string]interface{}{"type": "object"}}}, + System: "system", EnableCaching: true, ReasoningEffort: "high", + GLMThinkingEnabled: &thinking, TopP: &topP, ServiceTier: "priority", + MetadataUserID: "hawk-user-1", + ResponseFormat: &types.ResponseFormat{Type: "json_schema", Schema: `{"type":"object"}`}, + }, + types.ContinuationConfig{MaxContinuations: 2, MaxTotalTokens: 9000}, + ) + if request.Preference.PreferredProvider != "openrouter" || request.Preference.PreferredModelID != "openrouter/auto" { + t.Fatalf("selection lost: %+v", request.Preference) + } + if !request.Requirements.Tools || !request.Requirements.Vision || !request.Requirements.StructuredJSON || !request.Requirements.Reasoning { + t.Fatalf("requirements lost: %+v", request.Requirements) + } + if request.Limits.MaxContinuations != 2 || request.Limits.MaxTotalOutputTokens != 9000 { + t.Fatalf("continuation lost: %+v", request.Limits) + } + if !request.Options.EnableCaching || request.Options.ReasoningEffort != "high" || request.Options.GLMThinkingEnabled == nil || request.Options.TopP == nil || request.Options.ServiceTier != "priority" { + t.Fatalf("advanced options lost: %+v", request.Options) + } + if request.Metadata.UserID != "hawk-user-1" { + t.Fatalf("metadata user ID lost: %+v", request.Metadata) + } + if len(request.Messages) != 1 || len(request.Messages[0].ContentParts) != 1 || request.Messages[0].ContentParts[0].URL == "" { + t.Fatalf("multimodal message lost: %+v", request.Messages) + } +} + +func TestEngineAdapterOnlyRequiresGLMReasoningWhenEnabled(t *testing.T) { + disabled := false + enabled := true + + if request := toEngineRequest(nil, types.ChatOptions{GLMThinkingEnabled: &disabled}, types.ContinuationConfig{}); request.Requirements.Reasoning { + t.Fatalf("GLMThinkingEnabled=false unexpectedly requires reasoning: %+v", request.Requirements) + } + if request := toEngineRequest(nil, types.ChatOptions{GLMThinkingEnabled: &enabled}, types.ContinuationConfig{}); !request.Requirements.Reasoning { + t.Fatalf("GLMThinkingEnabled=true did not require reasoning: %+v", request.Requirements) + } +} + +func TestEngineAdapterNormalizesEventsForHawkLoop(t *testing.T) { + tests := []struct { + in eyrieengine.Event + wantType string + emit bool + provider string + model string + }{ + { + in: eyrieengine.Event{Type: eyrieengine.EventRouteSelected, Route: &eyrieengine.Route{ + Provider: "openai", Model: "openai/gpt-5", DeploymentRouting: true, + }}, + wantType: "route_selected", emit: true, provider: "openai", model: "openai/gpt-5", + }, + { + in: eyrieengine.Event{Type: eyrieengine.EventRouteChanged, Route: &eyrieengine.Route{ + Provider: "anthropic", Model: "anthropic/claude-sonnet-4-6", DeploymentRouting: true, + }}, + wantType: "route_changed", emit: true, provider: "anthropic", model: "anthropic/claude-sonnet-4-6", + }, + {in: eyrieengine.Event{Type: eyrieengine.EventContentDelta, Content: "x"}, wantType: "content", emit: true}, + {in: eyrieengine.Event{Type: eyrieengine.EventThinkingDelta}, wantType: "thinking", emit: true}, + {in: eyrieengine.Event{Type: eyrieengine.EventToolCallDone, ToolCall: &eyrieengine.ToolCall{Name: "read"}}, wantType: "tool_call", emit: true}, + {in: eyrieengine.Event{Type: eyrieengine.EventUsage, Usage: &eyrieengine.Usage{TotalTokens: 8}}, wantType: "usage", emit: true}, + {in: eyrieengine.Event{Type: eyrieengine.EventDone, StopReason: "end_turn", Usage: &eyrieengine.Usage{InputTokens: 5, OutputTokens: 3, TotalTokens: 8}}, wantType: "done", emit: true}, + } + for _, tt := range tests { + got, emit := fromEngineEvent(tt.in) + if emit != tt.emit || got.Type != tt.wantType { + t.Fatalf("event %q => type %q emit %v, want %q %v", tt.in.Type, got.Type, emit, tt.wantType, tt.emit) + } + if tt.provider != "" && (got.Route == nil || got.Route.Provider != tt.provider || got.Route.Model != tt.model || !got.Route.DeploymentRouting) { + t.Fatalf("event %q lost resolved route: %+v", tt.in.Type, got.Route) + } + if tt.in.Type == eyrieengine.EventDone && (got.Usage == nil || got.Usage.PromptTokens != 5 || got.Usage.CompletionTokens != 3 || got.Usage.TotalTokens != 8) { + t.Fatalf("terminal usage lost: %+v", got.Usage) + } + } +} + +func TestEngineAdapterPreservesResolvedRouteInBlockingResponse(t *testing.T) { + got := fromEngineResponse(&eyrieengine.GenerateResponse{ + Content: "ok", + Route: eyrieengine.Route{ + Provider: "openai", Model: "openai/gpt-5", DeploymentRouting: true, + }, + }) + if got.Route == nil || got.Route.Provider != "openai" || got.Route.Model != "openai/gpt-5" || !got.Route.DeploymentRouting { + t.Fatalf("blocking response lost resolved route: %+v", got.Route) + } +} diff --git a/internal/engine/mock_client_test.go b/internal/engine/mock_client_test.go index ce6bf4a8..1023c776 100644 --- a/internal/engine/mock_client_test.go +++ b/internal/engine/mock_client_test.go @@ -77,8 +77,6 @@ func (m *mockClient) StreamChatContinue(ctx context.Context, messages []types.Ey return &types.StreamResult{Events: ch}, nil } -func (m *mockClient) SetAPIKey(provider, apiKey string) {} - func (m *mockClient) callCount() int { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/engine/persistence_service.go b/internal/engine/persistence_service.go index ef6f2d40..572adc90 100644 --- a/internal/engine/persistence_service.go +++ b/internal/engine/persistence_service.go @@ -4,9 +4,8 @@ import ( "strings" "sync" - "github.com/GrayCodeAI/eyrie/storage" - "github.com/GrayCodeAI/hawk/internal/observability/logger" + "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -33,8 +32,8 @@ type PersistenceService struct { autoCompactThresholdPct int // contextWindowCached is the catalog context window; 0 → governor default. contextWindowCached int - // dag is the conversation DAG (for branching). - dag *storage.DAG + // graph is Hawk's product-owned conversation graph (for branching). + graph *session.ConversationGraph // steering is the per-iteration user-guidance queue. steering *SteeringQueue // logger. @@ -90,12 +89,11 @@ func (s *PersistenceService) RawMessages() []types.EyrieMessage { return s.messages } -// DAG returns the conversation DAG. New code should access this -// through s.Persistence().DAG(). -func (s *PersistenceService) DAG() *storage.DAG { return s.dag } +// Graph returns Hawk's product-owned conversation graph. +func (s *PersistenceService) Graph() *session.ConversationGraph { return s.graph } -// SetDAG attaches the conversation DAG. -func (s *PersistenceService) SetDAG(dag *storage.DAG) { s.dag = dag } +// SetGraph attaches the conversation graph. +func (s *PersistenceService) SetGraph(graph *session.ConversationGraph) { s.graph = graph } // Steering returns the per-iteration user-guidance queue. New // code should access this through s.Persistence().Steering(). diff --git a/internal/engine/provider_chat_client.go b/internal/engine/provider_chat_client.go index 3597c487..1ca248ea 100644 --- a/internal/engine/provider_chat_client.go +++ b/internal/engine/provider_chat_client.go @@ -7,16 +7,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/types" ) -// providerChatClient adapts any eyrie types.Provider to ChatClient (continuations + streaming). -type providerChatClient struct { - p types.ChatProvider -} - -// NewProviderChatClient wraps a catalog-backed provider (e.g. DeploymentRouter) for Session use. -func NewProviderChatClient(p types.ChatProvider) ChatClient { - return &providerChatClient{p: p} -} - // NewUnavailableChatClient preserves Session construction while surfacing // Eyrie transport setup failures at the first chat call. func NewUnavailableChatClient(err error) ChatClient { @@ -26,24 +16,6 @@ func NewUnavailableChatClient(err error) ChatClient { return &unavailableChatClient{err: err} } -func (w *providerChatClient) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { - if w == nil || w.p == nil { - return nil, errors.New("hawk: chat provider unavailable") - } - return w.p.Chat(ctx, messages, opts) -} - -func (w *providerChatClient) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, cfg types.ContinuationConfig) (*types.StreamResult, error) { - if w == nil || w.p == nil { - return nil, errors.New("hawk: chat provider unavailable") - } - return types.StreamChatWithContinuation(ctx, w.p, messages, opts, cfg) -} - -func (w *providerChatClient) SetAPIKey(_, _ string) { - // Credentials live on concrete adapters inside DeploymentRouter; Session env keys are unused here. -} - type unavailableChatClient struct { err error } @@ -55,5 +27,3 @@ func (c *unavailableChatClient) Chat(context.Context, []types.EyrieMessage, type func (c *unavailableChatClient) StreamChatContinue(context.Context, []types.EyrieMessage, types.ChatOptions, types.ContinuationConfig) (*types.StreamResult, error) { return nil, c.err } - -func (c *unavailableChatClient) SetAPIKey(_, _ string) {} diff --git a/internal/engine/resilience_boundary_test.go b/internal/engine/resilience_boundary_test.go new file mode 100644 index 00000000..002e8168 --- /dev/null +++ b/internal/engine/resilience_boundary_test.go @@ -0,0 +1,278 @@ +package engine + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +type resilienceBoundaryClient struct { + mu sync.Mutex + streams [][]types.EyrieStreamEvent + chatResponses []*types.EyrieResponse + streamCalls int + chatCalls int + messages [][]types.EyrieMessage +} + +type managedResilienceBoundaryClient struct { + *resilienceBoundaryClient +} + +func (*managedResilienceBoundaryClient) ManagesResilience() bool { return true } + +func (c *resilienceBoundaryClient) Chat(context.Context, []types.EyrieMessage, types.ChatOptions) (*types.EyrieResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.chatCalls++ + if len(c.chatResponses) == 0 { + return &types.EyrieResponse{}, nil + } + response := c.chatResponses[0] + c.chatResponses = c.chatResponses[1:] + return response, nil +} + +func (c *resilienceBoundaryClient) StreamChatContinue(_ context.Context, messages []types.EyrieMessage, _ types.ChatOptions, _ types.ContinuationConfig) (*types.StreamResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.streamCalls++ + c.messages = append(c.messages, cloneBoundaryMessages(messages)) + + events := []types.EyrieStreamEvent{{Type: "done", StopReason: "end_turn"}} + if len(c.streams) > 0 { + events = c.streams[0] + c.streams = c.streams[1:] + } + ch := make(chan types.EyrieStreamEvent, len(events)) + for _, event := range events { + ch <- event + } + close(ch) + return types.NewStreamResult(ch, "", nil), nil +} + +func (c *resilienceBoundaryClient) counts() (stream, chat int) { + c.mu.Lock() + defer c.mu.Unlock() + return c.streamCalls, c.chatCalls +} + +func (c *resilienceBoundaryClient) messagesForCall(call int) []types.EyrieMessage { + c.mu.Lock() + defer c.mu.Unlock() + return cloneBoundaryMessages(c.messages[call]) +} + +func cloneBoundaryMessages(messages []types.EyrieMessage) []types.EyrieMessage { + out := make([]types.EyrieMessage, len(messages)) + copy(out, messages) + for i := range out { + out[i].ToolUse = append([]types.ToolCall(nil), messages[i].ToolUse...) + out[i].ToolResults = append([]types.ToolResult(nil), messages[i].ToolResults...) + } + return out +} + +func runResilienceBoundarySession(t *testing.T, client ChatClient, configure func(*Session)) []StreamEvent { + t.Helper() + session := NewSessionWithClient(client, "test", "test-model", "test system", nil, false) + session.LifecycleSvc().Limits().SetMaxTurns(5) + if configure != nil { + configure(session) + } + session.AddUser("test resilience boundary") + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + stream, err := session.Stream(ctx) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + var events []StreamEvent + for event := range stream { + events = append(events, event) + } + return events +} + +func TestAgentLoopResilienceBoundary_StreamRetry(t *testing.T) { + transient := []types.EyrieStreamEvent{{Type: "error", Error: "temporary connection reset by peer"}} + success := []types.EyrieStreamEvent{ + {Type: "content", Content: "recovered"}, + {Type: "done", StopReason: "end_turn"}, + } + + t.Run("engine facade gets one stream attempt", func(t *testing.T) { + base := &resilienceBoundaryClient{streams: [][]types.EyrieStreamEvent{transient, success}} + events := runResilienceBoundarySession(t, &managedResilienceBoundaryClient{base}, nil) + streamCalls, chatCalls := base.counts() + if streamCalls != 1 || chatCalls != 0 { + t.Fatalf("calls = stream %d, chat %d; want exactly one stream call", streamCalls, chatCalls) + } + if !hasBoundaryEvent(events, "error", "temporary connection reset") { + t.Fatalf("managed stream error was not surfaced: %#v", events) + } + }) + + t.Run("legacy client retains stream retry", func(t *testing.T) { + client := &resilienceBoundaryClient{streams: [][]types.EyrieStreamEvent{transient, success}} + events := runResilienceBoundarySession(t, client, nil) + streamCalls, chatCalls := client.counts() + if streamCalls != 2 || chatCalls != 0 { + t.Fatalf("calls = stream %d, chat %d; want two stream calls", streamCalls, chatCalls) + } + if !hasBoundaryEvent(events, "retry", "transient stream error") || !hasBoundaryEvent(events, "content", "recovered") { + t.Fatalf("legacy retry behavior missing: %#v", events) + } + }) +} + +func TestAgentLoopResilienceBoundary_ThinkingOnlyRecovery(t *testing.T) { + thinkingOnly := []types.EyrieStreamEvent{ + {Type: "thinking", Thinking: "internal reasoning"}, + {Type: "done", StopReason: "end_turn"}, + } + fallback := &types.EyrieResponse{Content: "compatibility reply", FinishReason: "end_turn"} + + t.Run("engine facade does not trigger Hawk protocol recovery", func(t *testing.T) { + base := &resilienceBoundaryClient{ + streams: [][]types.EyrieStreamEvent{thinkingOnly}, + chatResponses: []*types.EyrieResponse{fallback}, + } + events := runResilienceBoundarySession(t, &managedResilienceBoundaryClient{base}, nil) + streamCalls, chatCalls := base.counts() + if streamCalls != 1 || chatCalls != 0 { + t.Fatalf("calls = stream %d, chat %d; want one stream and no protocol fallback", streamCalls, chatCalls) + } + if hasBoundaryEvent(events, "content", "compatibility reply") { + t.Fatalf("managed client unexpectedly used Hawk thinking fallback: %#v", events) + } + }) + + t.Run("legacy client retains thinking-only fallback", func(t *testing.T) { + client := &resilienceBoundaryClient{ + streams: [][]types.EyrieStreamEvent{thinkingOnly}, + chatResponses: []*types.EyrieResponse{fallback}, + } + events := runResilienceBoundarySession(t, client, nil) + streamCalls, chatCalls := client.counts() + if streamCalls != 1 || chatCalls != 1 { + t.Fatalf("calls = stream %d, chat %d; want stream plus compatibility Chat", streamCalls, chatCalls) + } + if !hasBoundaryEvent(events, "content", "compatibility reply") { + t.Fatalf("legacy thinking fallback missing: %#v", events) + } + }) +} + +func TestAgentLoopResilienceBoundary_MaxTokensContinuation(t *testing.T) { + partial := []types.EyrieStreamEvent{ + {Type: "content", Content: "partial"}, + {Type: "done", StopReason: "max_tokens"}, + } + finished := []types.EyrieStreamEvent{ + {Type: "content", Content: " finished"}, + {Type: "done", StopReason: "end_turn"}, + } + + t.Run("engine facade does not get a synthetic continuation turn", func(t *testing.T) { + base := &resilienceBoundaryClient{streams: [][]types.EyrieStreamEvent{partial, finished}} + session := NewSessionWithClient(&managedResilienceBoundaryClient{base}, "test", "test-model", "test system", nil, false) + session.LifecycleSvc().Limits().SetMaxTurns(5) + session.AddUser("continue test") + drainBoundarySession(t, session) + + streamCalls, chatCalls := base.counts() + if streamCalls != 1 || chatCalls != 0 { + t.Fatalf("calls = stream %d, chat %d; want exactly one facade stream", streamCalls, chatCalls) + } + messages := session.Persistence().RawMessages() + if len(messages) != 2 || messages[1].Role != "assistant" || messages[1].Content != "partial" { + t.Fatalf("managed conversation = %#v, want user plus partial assistant", messages) + } + for _, message := range messages { + if strings.Contains(message.Content, "Continue from where you left off") { + t.Fatalf("synthetic continuation leaked into managed conversation: %#v", messages) + } + } + }) + + t.Run("legacy client retains synthetic continuation", func(t *testing.T) { + client := &resilienceBoundaryClient{streams: [][]types.EyrieStreamEvent{partial, finished}} + session := NewSessionWithClient(client, "test", "test-model", "test system", nil, false) + session.LifecycleSvc().Limits().SetMaxTurns(5) + session.AddUser("continue test") + drainBoundarySession(t, session) + + streamCalls, chatCalls := client.counts() + if streamCalls != 2 || chatCalls != 0 { + t.Fatalf("calls = stream %d, chat %d; want two compatibility streams", streamCalls, chatCalls) + } + messages := session.Persistence().RawMessages() + if len(messages) != 4 || messages[2].Content != "Continue from where you left off." || messages[3].Content != " finished" { + t.Fatalf("legacy continuation conversation = %#v", messages) + } + }) +} + +func TestAgentLoopResilienceBoundary_KeepsHawkToolAuthorizationAndMutation(t *testing.T) { + base := &resilienceBoundaryClient{streams: [][]types.EyrieStreamEvent{ + { + {Type: "tool_call", ToolCall: &types.ToolCall{ID: "write-1", Name: "Write", Arguments: map[string]interface{}{"file_path": "blocked.txt", "content": "no"}}}, + {Type: "done", StopReason: "tool_use"}, + }, + { + {Type: "content", Content: "handled denial"}, + {Type: "done", StopReason: "end_turn"}, + }, + }} + session := NewSessionWithClient(&managedResilienceBoundaryClient{base}, "test", "test-model", "test system", nil, false) + session.LifecycleSvc().Limits().SetMaxTurns(5) + session.PermSvc().SetDryRun(true) + session.AddUser("attempt a write") + events := drainBoundarySession(t, session) + + streamCalls, chatCalls := base.counts() + if streamCalls != 2 || chatCalls != 0 { + t.Fatalf("calls = stream %d, chat %d; want one facade call per Hawk agent turn", streamCalls, chatCalls) + } + if !hasBoundaryEvent(events, "tool_result", "dry-run") { + t.Fatalf("Hawk tool denial was not emitted: %#v", events) + } + secondRequest := base.messagesForCall(1) + if len(secondRequest) < 3 || len(secondRequest[1].ToolUse) != 1 || len(secondRequest[2].ToolResults) != 1 { + t.Fatalf("Hawk did not persist tool call/result conversation shape: %#v", secondRequest) + } + if !secondRequest[2].ToolResults[0].IsError || !strings.Contains(secondRequest[2].ToolResults[0].Content, "dry-run") { + t.Fatalf("tool authorization result = %#v, want persisted denial", secondRequest[2].ToolResults[0]) + } +} + +func drainBoundarySession(t *testing.T, session *Session) []StreamEvent { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + stream, err := session.Stream(ctx) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + var events []StreamEvent + for event := range stream { + events = append(events, event) + } + return events +} + +func hasBoundaryEvent(events []StreamEvent, eventType, content string) bool { + for _, event := range events { + if event.Type == eventType && strings.Contains(event.Content, content) { + return true + } + } + return false +} diff --git a/internal/engine/session.go b/internal/engine/session.go index d938ae6f..3ff5415b 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -9,9 +9,9 @@ import ( "sync" "time" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" "github.com/GrayCodeAI/hawk/internal/types" - "github.com/GrayCodeAI/eyrie/storage" "github.com/GrayCodeAI/hawk/internal/engine/branching" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/observability/logger" @@ -55,7 +55,7 @@ type SnapshotTracker interface { // persist *PersistenceService (Phase 5: conversation store) // tools *ToolService (Phase 6: tool execution) // -// The legacy fields (client, provider, model, apiKeys, Router, +// The legacy fields (client, provider, model, Router, // DeploymentRouting, RateLimiter, Perm, Permissions, AutoMode, // Classifier, BypassKill, MaxTurns, MaxBudgetUSD, AllowedDirs, // PermissionFn, Autonomy, Approval, Memory, YaadBridge, EnhancedMemory, @@ -73,12 +73,10 @@ type Session struct { messages []types.EyrieMessage provider string model string - apiKeys map[string]string system string log *logger.Logger metrics *metrics.Registry Cost Cost - Router *modelPkg.Router // DeploymentRouting is true when the chat client is catalog-backed (e.g. DeploymentRouter). // // Deprecated: use s.ChatLLM().DeploymentRouting() (Phase 1 sub-service). @@ -95,7 +93,7 @@ type Session struct { // llm is the LLM transport service (Phase 1 extraction). All new // code should go through s.llm.* rather than touching the legacy - // client/provider/model/apiKeys/Router/DeploymentRouting fields. + // client/provider/model/Router/DeploymentRouting fields. // Named lowercase (unexported) to avoid colliding with the public // Session.Chat() method used by Reflector and SelfReview. llm *ChatService @@ -122,7 +120,7 @@ type Session struct { // Deprecated: use s.LifecycleSvc() (Phase 3 sub-service) for: // MaxBudgetUSD, AllowedDirs, Memory, YaadBridge, // EnhancedMemory, Cascade, Lifecycle, Reflector, CostTracker, - // ConvoDAG, Sleeptime, Activity, SkillDistiller, AutoCompactor, + // ConversationGraph, Sleeptime, Activity, SkillDistiller, AutoCompactor, // FewShotStore, AdaptivePrompt. AllowedDirs []string PermissionFn func(PermissionRequest) // use Perm.PromptFn @@ -179,7 +177,7 @@ type Session struct { // Limits -> s.LifecycleSvc().Limits() // Trajectory -> legacy field; not yet on a sub-service // Shadow -> s.LifecycleSvc().Shadow() - // ConvoDAG -> s.Persistence().DAG() + // ConversationGraph -> s.Persistence().Graph() // Sleeptime -> s.MemorySvc().Sleeptime() // Activity -> s.MemorySvc().Activity() // SkillDistiller -> s.MemorySvc().SkillDistiller() @@ -196,31 +194,31 @@ type Session struct { // Steering -> s.Persistence().Steering() // Snapshots -> legacy field; not yet on Persistence // Tracer -> legacy field; oteltrace.NewTracer() for new code - Autonomy AutonomyLevel // autonomy.go — permission level - Sandbox *DiffSandbox // diffsandbox.go — staged file changes - Plan *PlanState // subtask.go — user-activated plan - Beliefs *BeliefState // belief.go — discovered knowledge - Critic *Critic // critic.go — patch pre-screening - Backtrack *BacktrackEngine // backtrack.go — decision recording - Limits *LimitTracker // limits.go — safety limits - Teach TeachConfig // teach.go — explanation depth - Trajectory *TrajectoryDistiller // trajectory.go — multi-run distillation - Shadow *branching.ShadowWorkspace // shadow.go — edit pre-validation - Snapshots SnapshotTracker // snapshot integration for auto-tracking - ConvoDAG *storage.DAG // conversation DAG for branching/forking - Sleeptime *memory.SleeptimeAgent // sleeptime.go — background memory consolidation - Activity *memory.ActivityTracker // activity.go — memory save nudging (Engram pattern) - SkillDistiller *memory.SkillDistiller // skill_distill.go — auto-skill extraction - Tracer *oteltrace.Tracer // oteltrace.go — distributed tracing spans - LintLoop *LintLoop // lint_loop.go — auto lint-fix reflected messages - TestLoop *TestLoop // test_loop.go — auto test-fix loop - FileMentions *FileMentionDetector // file_mentions.go — detect referenced files - ResponseCache *ResponseCache // response_cache.go — cache similar prompts - Pipeline *IntegrationPipeline // integration.go — unified feature orchestration - Files *FileTracker // compact_files.go — cumulative file tracking across compactions - Steering *SteeringQueue // steering.go — user guidance injection between tool batches - RateLimiter *ratelimit.Limiter // ratelimit — token bucket for LLM API calls - AgentsAccum *prompts.AgentsAccumulator // agents_accumulator.go — auto-capture learnings + Autonomy AutonomyLevel // autonomy.go — permission level + Sandbox *DiffSandbox // diffsandbox.go — staged file changes + Plan *PlanState // subtask.go — user-activated plan + Beliefs *BeliefState // belief.go — discovered knowledge + Critic *Critic // critic.go — patch pre-screening + Backtrack *BacktrackEngine // backtrack.go — decision recording + Limits *LimitTracker // limits.go — safety limits + Teach TeachConfig // teach.go — explanation depth + Trajectory *TrajectoryDistiller // trajectory.go — multi-run distillation + Shadow *branching.ShadowWorkspace // shadow.go — edit pre-validation + Snapshots SnapshotTracker // snapshot integration for auto-tracking + ConversationGraph *session.ConversationGraph // Hawk-owned conversation branching/forking + Sleeptime *memory.SleeptimeAgent // sleeptime.go — background memory consolidation + Activity *memory.ActivityTracker // activity.go — memory save nudging (Engram pattern) + SkillDistiller *memory.SkillDistiller // skill_distill.go — auto-skill extraction + Tracer *oteltrace.Tracer // oteltrace.go — distributed tracing spans + LintLoop *LintLoop // lint_loop.go — auto lint-fix reflected messages + TestLoop *TestLoop // test_loop.go — auto test-fix loop + FileMentions *FileMentionDetector // file_mentions.go — detect referenced files + ResponseCache *ResponseCache // response_cache.go — cache similar prompts + Pipeline *IntegrationPipeline // integration.go — unified feature orchestration + Files *FileTracker // compact_files.go — cumulative file tracking across compactions + Steering *SteeringQueue // steering.go — user guidance injection between tool batches + RateLimiter *ratelimit.Limiter // ratelimit — token bucket for LLM API calls + AgentsAccum *prompts.AgentsAccumulator // agents_accumulator.go — auto-capture learnings // Few-shot learning and prompt optimization // @@ -242,9 +240,12 @@ type Session struct { smartSkills []plugin.SmartSkill } -// NewSession creates a new conversation session with a legacy string-named provider. +// NewSession creates a conversation session through Eyrie's engine facade. func NewSession(provider, model, systemPrompt string, registry *tool.Registry) *Session { - return NewSessionWithClient(types.NewClient(&types.ClientConfig{Provider: provider}), provider, model, systemPrompt, registry, false) + return NewHawkSession(context.Background(), eyrieengine.Selection{ + Provider: provider, + Model: model, + }, provider, model, systemPrompt, registry) } // NewSessionWithClient constructs a session with an explicit LLM client (e.g. deployment router). @@ -259,7 +260,6 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, registry: registry, provider: provider, model: model, - apiKeys: map[string]string{}, system: systemPrompt, log: log, metrics: metrics.NewRegistry(), @@ -281,7 +281,6 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, RateLimiter: ratelimit.PerSecond(10), } s.Cost.Model = model - s.Router = modelPkg.NewRouter(modelPkg.StrategyBalanced) s.AutoCompactThresholdPct = DefaultAutoCompactThresholdPct s.refreshContextWindowCache() @@ -305,8 +304,6 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.llm = NewChatService(chat, ChatServiceConfig{ Provider: provider, Model: model, - APIKeys: s.apiKeys, - Router: s.Router, DeploymentRouting: deploymentRouting, RateLimiter: s.RateLimiter, Metrics: s.metrics, @@ -322,7 +319,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, // the same state as new code that goes through the sub-service getters. // After this point, mutations to the sub-service internal state // (e.g., s.memory.SetMemory(...)) need a corresponding write to the - // legacy field — see the various Set* helpers (SetConvoDAG, + // legacy field — see the various Set* helpers (SetConversationGraph, // SetSnapshots, etc.) which perform the dual write. s.Limits = s.life.Limits() s.Beliefs = s.life.Beliefs() @@ -335,7 +332,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.Memory = s.memory.Memory() s.YaadBridge = s.memory.Yaad() s.EnhancedMemory = s.memory.Enhanced() - s.ConvoDAG = s.persist.DAG() + s.ConversationGraph = s.persist.Graph() s.Steering = s.persist.Steering() return s @@ -356,11 +353,6 @@ func (s *Session) ReattachTransport(chat ChatClient, provider string, deployment if s.llm != nil { s.llm.Reattach(chat, s.provider) } - for name, key := range s.apiKeys { - if strings.TrimSpace(key) != "" { - s.client.SetAPIKey(name, key) - } - } } // SubSession clones transport and routing mode for explore/general sub-agents. @@ -369,9 +361,6 @@ func (s *Session) SubSession(model, systemPrompt string, registry *tool.Registry registry = s.registry } sub := NewSessionWithClient(s.client, s.provider, model, systemPrompt, registry, s.DeploymentRouting) - for provider, key := range s.apiKeys { - sub.SetAPIKey(provider, key) - } return sub } @@ -381,7 +370,7 @@ func (s *Session) Metrics() *metrics.Registry { return s.metrics } // ChatLLM returns the extracted ChatService (Phase 1 of the god-object // decomposition). New code should prefer this over the legacy Client / -// Provider / Model / APIKeys / Router fields. Returns nil only if the +// Provider / Model / Router fields. Returns nil only if the // session was constructed without going through NewSessionWithClient, // which should not happen in production. func (s *Session) ChatLLM() *ChatService { return s.llm } @@ -468,54 +457,20 @@ func (s *Session) syncCascadeDefaultModel() { func (s *Session) SetProvider(provider string) { p := strings.TrimSpace(provider) s.provider = p - if s.DeploymentRouting { - return - } - s.client = types.NewClient(&types.ClientConfig{Provider: p}) - // Copy keys to avoid map iteration race with concurrent SetAPIKey calls. - keys := make(map[string]string, len(s.apiKeys)) - for k, v := range s.apiKeys { - keys[k] = v - } - for provider, apiKey := range keys { - if strings.TrimSpace(apiKey) != "" { - s.client.SetAPIKey(provider, apiKey) - } - } -} - -// SetAPIKey updates a provider API key for subsequent requests. -func (s *Session) SetAPIKey(provider, apiKey string) { - provider = strings.ToLower(strings.TrimSpace(provider)) - apiKey = strings.TrimSpace(apiKey) - if provider == "" || apiKey == "" { - return - } - if s.apiKeys == nil { - s.apiKeys = map[string]string{} - } - s.apiKeys[provider] = apiKey - if s.client != nil { - s.client.SetAPIKey(provider, apiKey) - } -} - -// SetAPIKeys updates all known provider API keys for subsequent requests. -func (s *Session) SetAPIKeys(apiKeys map[string]string) { - for provider, apiKey := range apiKeys { - s.SetAPIKey(provider, apiKey) + if s.llm != nil { + s.llm.SetProvider(p) } } func (s *Session) AddUser(content string) { if p := s.Persistence(); p != nil { p.AddUser(content) - if dag := p.DAG(); dag != nil { + if graph := p.Graph(); graph != nil { parentID := "" - if head, err := dag.Head(context.Background()); err == nil && head != nil { + if head, err := graph.Head(); err == nil && head != nil { parentID = head.ID } - _, _ = dag.Append(context.Background(), parentID, "user", content) + _, _ = graph.Append(parentID, "user", content) } } if memSvc := s.MemorySvc(); memSvc != nil { @@ -538,12 +493,12 @@ func (s *Session) AddUser(content string) { func (s *Session) AddUserWithImage(content string, imageBase64 string, imageType string) { if p := s.Persistence(); p != nil { p.AddUser(content + " [image attached]") - if dag := p.DAG(); dag != nil { + if graph := p.Graph(); graph != nil { parentID := "" - if head, err := dag.Head(context.Background()); err == nil && head != nil { + if head, err := graph.Head(); err == nil && head != nil { parentID = head.ID } - _, _ = dag.Append(context.Background(), parentID, "user", content+" [image attached]") + _, _ = graph.Append(parentID, "user", content+" [image attached]") } } s.mu.Lock() @@ -559,12 +514,12 @@ func (s *Session) AddUserWithImage(content string, imageBase64 string, imageType func (s *Session) AddAssistant(content string) { if p := s.Persistence(); p != nil { p.AddAssistant(content) - if dag := p.DAG(); dag != nil { + if graph := p.Graph(); graph != nil { parentID := "" - if head, err := dag.Head(context.Background()); err == nil && head != nil { + if head, err := graph.Head(); err == nil && head != nil { parentID = head.ID } - _, _ = dag.Append(context.Background(), parentID, "assistant", content) + _, _ = graph.Append(parentID, "assistant", content) } } } @@ -576,16 +531,16 @@ func (s *Session) ForkConversation(nodeID string) (string, error) { if p == nil { return "", nil } - dag := p.DAG() - if dag == nil { + graph := p.Graph() + if graph == nil { return "", nil } - fork, err := dag.Fork(context.Background(), nodeID) + fork, err := graph.Fork(nodeID) if err != nil { return "", err } // Rebuild messages from the forked branch. - history, err := dag.History(context.Background(), fork.ID) + history, err := graph.History(fork.ID) if err != nil { return "", err } @@ -608,14 +563,14 @@ func (s *Session) SwitchBranch(nodeID string) error { if p == nil { return nil } - dag := p.DAG() - if dag == nil { + graph := p.Graph() + if graph == nil { return nil } - if err := dag.SetHead(context.Background(), nodeID); err != nil { + if err := graph.SetHead(nodeID); err != nil { return err } - history, err := dag.History(context.Background(), nodeID) + history, err := graph.History(nodeID) if err != nil { return err } @@ -633,16 +588,16 @@ func (s *Session) SwitchBranch(nodeID string) error { } // ListBranches returns child nodes (alternative branches) from a given node. -func (s *Session) ListBranches(nodeID string) ([]*storage.DAGNode, error) { +func (s *Session) ListBranches(nodeID string) ([]*session.ConversationNode, error) { p := s.Persistence() if p == nil { return nil, nil } - dag := p.DAG() - if dag == nil { + graph := p.Graph() + if graph == nil { return nil, nil } - return dag.Branches(context.Background(), nodeID) + return graph.Branches(nodeID) } // ConvoHead returns the current conversation head node ID. @@ -651,11 +606,11 @@ func (s *Session) ConvoHead() string { if p == nil { return "" } - dag := p.DAG() - if dag == nil { + graph := p.Graph() + if graph == nil { return "" } - if head, err := dag.Head(context.Background()); err == nil && head != nil { + if head, err := graph.Head(); err == nil && head != nil { return head.ID } return "" @@ -790,14 +745,25 @@ func (s *Session) SetApproval(a *ApprovalGate) { s.Approval = a } -// SetConvoDAG attaches the conversation DAG. New code should -// call this instead of writing to the legacy s.ConvoDAG field. -// The DAG is also propagated to the persistence service so -// s.Persistence().DAG() and the legacy field stay in sync. -func (s *Session) SetConvoDAG(dag *storage.DAG) { - s.ConvoDAG = dag +// SetConversationGraph attaches Hawk's product-owned conversation graph and +// seeds it from an already-resumed linear transcript when the graph is new. +func (s *Session) SetConversationGraph(graph *session.ConversationGraph) { + s.ConversationGraph = graph if s.persist != nil { - s.persist.SetDAG(dag) + s.persist.SetGraph(graph) + if graph != nil && graph.Empty() { + parentID := "" + for _, message := range s.persist.RawMessages() { + if message.Role != "user" && message.Role != "assistant" { + continue + } + node, err := graph.Append(parentID, message.Role, message.Content) + if err != nil { + break + } + parentID = node.ID + } + } } } @@ -898,8 +864,10 @@ type StreamEvent struct { // StreamUsage tracks token usage for a single stream event. type StreamUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - CacheWriteTokens int `json:"cache_write_tokens,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` } diff --git a/internal/engine/session_factory.go b/internal/engine/session_factory.go index 53aa6f40..ed164c02 100644 --- a/internal/engine/session_factory.go +++ b/internal/engine/session_factory.go @@ -6,14 +6,23 @@ import ( "fmt" "strings" - "github.com/GrayCodeAI/eyrie/runtime" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/tool" - "github.com/GrayCodeAI/hawk/internal/types" ) // BuildChatClient returns an LLM client and whether deployment routing is active. -func BuildChatClient(ctx context.Context, selection runtime.SelectionState, legacyProvider string) (ChatClient, string, bool, error) { +func BuildChatClient(ctx context.Context, selection eyrieengine.Selection, legacyProvider string) (ChatClient, string, bool, error) { + modelRuntime, err := hawkconfig.NewEyrieEngine() + if err != nil { + return nil, requestedProvider(selection, legacyProvider), false, fmt.Errorf("eyrie transport: %w", err) + } + return buildChatClientWithRuntime(ctx, modelRuntime, selection, legacyProvider) +} + +func buildChatClientWithRuntime(ctx context.Context, modelRuntime *eyrieengine.Engine, selection eyrieengine.Selection, legacyProvider string) (ChatClient, string, bool, error) { + _ = ctx // request contexts are applied per generation by the facade adapter provider := strings.TrimSpace(selection.Provider) if provider == "" { provider = legacyProvider @@ -22,22 +31,35 @@ func BuildChatClient(ctx context.Context, selection runtime.SelectionState, lega if strings.TrimSpace(resolvedSelection.Provider) == "" { resolvedSelection.Provider = provider } - transport, err := runtime.ResolveChatTransportFromSelection(ctx, resolvedSelection) - if err == nil && transport.Provider != nil { - label := strings.TrimSpace(transport.Selection.Provider) - if label == "" { - label = provider - } - return NewProviderChatClient(types.WrapClientProvider(transport.Provider)), label, transport.Selection.DeploymentRouting, nil + if modelRuntime == nil { + return nil, provider, false, errors.New("eyrie transport: runtime is nil") } + label := strings.TrimSpace(resolvedSelection.Provider) + if label == "" { + label = provider + } + return newEyrieEngineClient(modelRuntime), label, true, nil +} + +// BuildChatClientForSettings composes the model runtime from one effective +// settings snapshot. It is the command-facing path for --settings isolation. +func BuildChatClientForSettings(ctx context.Context, settings hawkconfig.Settings, selection eyrieengine.Selection, legacyProvider string) (ChatClient, string, bool, error) { + modelRuntime, err := hawkconfig.NewEyrieEngineForSettings(settings) if err != nil { - return nil, provider, false, fmt.Errorf("eyrie transport: %w", err) + return nil, requestedProvider(selection, legacyProvider), false, fmt.Errorf("eyrie transport: %w", err) } - return nil, provider, false, fmt.Errorf("eyrie transport: no provider resolved for %q", provider) + return buildChatClientWithRuntime(ctx, modelRuntime, selection, legacyProvider) +} + +func requestedProvider(selection eyrieengine.Selection, legacyProvider string) string { + if provider := strings.TrimSpace(selection.Provider); provider != "" { + return provider + } + return strings.TrimSpace(legacyProvider) } // NewHawkSession constructs a Session using an engine-resolved selection. -func NewHawkSession(ctx context.Context, selection runtime.SelectionState, provider, model, systemPrompt string, registry *tool.Registry) *Session { +func NewHawkSession(ctx context.Context, selection eyrieengine.Selection, provider, model, systemPrompt string, registry *tool.Registry) *Session { chat, label, deploy, err := BuildChatClient(ctx, selection, provider) if err != nil { chat = NewUnavailableChatClient(err) @@ -49,8 +71,22 @@ func NewHawkSession(ctx context.Context, selection runtime.SelectionState, provi return NewSessionWithClient(chat, label, resolvedModel, systemPrompt, registry, deploy) } +// NewHawkSessionForSettings constructs a session with an invocation-scoped +// Eyrie engine, including custom gateways from an explicit settings file. +func NewHawkSessionForSettings(ctx context.Context, settings hawkconfig.Settings, selection eyrieengine.Selection, provider, model, systemPrompt string, registry *tool.Registry) *Session { + chat, label, deploy, err := BuildChatClientForSettings(ctx, settings, selection, provider) + if err != nil { + chat = NewUnavailableChatClient(err) + } + resolvedModel := strings.TrimSpace(selection.Model) + if resolvedModel == "" { + resolvedModel = model + } + return NewSessionWithClient(chat, label, resolvedModel, systemPrompt, registry, deploy) +} + // RebuildSessionTransport rebuilds the LLM client from the engine-resolved selection. -func RebuildSessionTransport(ctx context.Context, s *Session, selection runtime.SelectionState, legacyProvider string) error { +func RebuildSessionTransport(ctx context.Context, s *Session, selection eyrieengine.Selection, legacyProvider string) error { if s == nil { return errors.New("session is nil") } @@ -61,3 +97,15 @@ func RebuildSessionTransport(ctx context.Context, s *Session, selection runtime. s.ReattachTransport(chat, label, deploy) return nil } + +func RebuildSessionTransportForSettings(ctx context.Context, settings hawkconfig.Settings, s *Session, selection eyrieengine.Selection, legacyProvider string) error { + if s == nil { + return errors.New("session is nil") + } + chat, label, deploy, err := BuildChatClientForSettings(ctx, settings, selection, legacyProvider) + if err != nil { + return err + } + s.ReattachTransport(chat, label, deploy) + return nil +} diff --git a/internal/engine/session_factory_test.go b/internal/engine/session_factory_test.go index 73c8fca7..fd35f796 100644 --- a/internal/engine/session_factory_test.go +++ b/internal/engine/session_factory_test.go @@ -4,11 +4,13 @@ import ( "context" "testing" - "github.com/GrayCodeAI/eyrie/runtime" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func TestNewHawkSession_UsesResolvedSelectionModel(t *testing.T) { - selection := runtime.SelectionState{ + selection := eyrieengine.Selection{ Provider: "openrouter", Model: "openrouter/auto", DeploymentRouting: false, @@ -23,8 +25,23 @@ func TestNewHawkSession_UsesResolvedSelectionModel(t *testing.T) { } } +func TestBuildChatClientForSettingsComposesCustomGateway(t *testing.T) { + settings := hawkconfig.Settings{CustomProviders: []hawkconfig.CustomProviderConfig{{ + Name: "private-gateway", BaseURL: "https://private.example.test/v1", + APIKeyEnv: "PRIVATE_GATEWAY_API_KEY", Model: "private/model-v1", + }}} + selection := eyrieengine.Selection{Provider: "private-gateway", Model: "private/model-v1"} + client, label, deployment, err := BuildChatClientForSettings(context.Background(), settings, selection, "") + if err != nil { + t.Fatal(err) + } + if client == nil || label != "private-gateway" || !deployment { + t.Fatalf("client=%T label=%q deployment=%v", client, label, deployment) + } +} + func TestNewHawkSession_FallsBackToCallerModelWhenSelectionEmpty(t *testing.T) { - selection := runtime.SelectionState{ + selection := eyrieengine.Selection{ Provider: "openrouter", DeploymentRouting: false, } diff --git a/internal/engine/session_h3_h4_test.go b/internal/engine/session_h3_h4_test.go index d9b4cca6..092479bc 100644 --- a/internal/engine/session_h3_h4_test.go +++ b/internal/engine/session_h3_h4_test.go @@ -5,40 +5,38 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/storage" + "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/types" ) -// TestSession_SetConvoDAG_DualWrite is a regression guard for the -// H3 fix: SetConvoDAG must propagate the DAG to the persistence -// service, not just the legacy field. Otherwise new code reading -// s.Persistence().DAG() would see nil even after the field was set. -func TestSession_SetConvoDAG_DualWrite(t *testing.T) { +// TestSession_SetConversationGraph_DualWrite guards the product-owned graph +// wiring between Session and PersistenceService. +func TestSession_SetConversationGraph_DualWrite(t *testing.T) { t.Parallel() mc := newMockClient() s := newMockSession(mc) - if s.Persistence().DAG() != nil { - t.Fatal("DAG should be nil before SetConvoDAG") + if s.Persistence().Graph() != nil { + t.Fatal("graph should be nil before SetConversationGraph") } - // Build a real DAG backed by a temp SQLite store and attach it. + // Build a real Hawk graph backed by a temporary JSON store and attach it. dir := t.TempDir() - dag, err := storage.NewDAG(filepath.Join(dir, "convo.db"), "test-session") + graph, err := session.OpenConversationGraph(filepath.Join(dir, "conversation.json"), "test-session") if err != nil { - t.Fatalf("storage.NewDAG: %v", err) + t.Fatalf("OpenConversationGraph: %v", err) } - t.Cleanup(func() { _ = dag.Close() }) - s.SetConvoDAG(dag) + t.Cleanup(func() { _ = graph.Close() }) + s.SetConversationGraph(graph) - if s.ConvoDAG == nil { - t.Error("s.ConvoDAG is nil after SetConvoDAG") + if s.ConversationGraph == nil { + t.Error("s.ConversationGraph is nil after SetConversationGraph") } - if s.Persistence().DAG() == nil { - t.Error("s.Persistence().DAG() is nil after SetConvoDAG — H3 regression: SetConvoDAG must dual-write to the persistence service") + if s.Persistence().Graph() == nil { + t.Error("s.Persistence().Graph() is nil after SetConversationGraph") } - if s.Persistence().DAG() != s.ConvoDAG { - t.Error("s.Persistence().DAG() != s.ConvoDAG — both views should be the same instance") + if s.Persistence().Graph() != s.ConversationGraph { + t.Error("persistence graph and session graph should be the same instance") } } @@ -58,8 +56,8 @@ func TestSession_NewSessionWithClient_AliasesMemoryFields(t *testing.T) { if s.persist == nil { t.Fatal("s.persist is nil; NewSessionWithClient must wire the persistence service") } - if got := s.persist.DAG(); got != s.ConvoDAG { - t.Errorf("s.persist.DAG() = %v, want same as s.ConvoDAG = %v", got, s.ConvoDAG) + if got := s.persist.Graph(); got != s.ConversationGraph { + t.Errorf("s.persist.Graph() = %v, want same as s.ConversationGraph = %v", got, s.ConversationGraph) } if got := s.persist.Steering(); got != s.Steering { t.Errorf("s.persist.Steering() = %v, want same as s.Steering = %v", got, s.Steering) diff --git a/internal/engine/session_services.go b/internal/engine/session_services.go index c4cc307b..c97d4969 100644 --- a/internal/engine/session_services.go +++ b/internal/engine/session_services.go @@ -13,14 +13,13 @@ package engine import ( "github.com/GrayCodeAI/hawk/internal/types" - "github.com/GrayCodeAI/eyrie/storage" "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/observability/metrics" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/permissions" - modelPkg "github.com/GrayCodeAI/hawk/internal/provider/routing" + "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/tool" ) @@ -36,7 +35,6 @@ type CoreLoop struct { Messages []types.EyrieMessage Provider string Model string - APIKeys map[string]string System string Log *logger.Logger MaxTurns int @@ -95,7 +93,6 @@ type Optimizer struct { Cost Cost CostTracker *CostTracker Cascade *branching.CascadeRouter - Router *modelPkg.Router MaxBudget float64 } @@ -138,17 +135,17 @@ type SessionServices struct { Observe *Observability // Advanced features (optional, nil when unused) - Lifecycle *SessionLifecycle - Reflector *Reflector - Critic *Critic - Backtrack *BacktrackEngine - Shadow *branching.ShadowWorkspace - ConvoDAG *storage.DAG - Plan *PlanState - Teach TeachConfig - Trajectory *TrajectoryDistiller - Snapshots SnapshotTracker - LintLoop *LintLoop + Lifecycle *SessionLifecycle + Reflector *Reflector + Critic *Critic + Backtrack *BacktrackEngine + Shadow *branching.ShadowWorkspace + ConversationGraph *session.ConversationGraph + Plan *PlanState + Teach TeachConfig + Trajectory *TrajectoryDistiller + Snapshots SnapshotTracker + LintLoop *LintLoop } // lintLoopPlaceholder is kept for documentation; see lint_loop.go @@ -271,8 +268,7 @@ func WithMaxBudget(budget float64) ServiceOption { func NewSessionServices(opts ...ServiceOption) *SessionServices { ss := &SessionServices{ Core: &CoreLoop{ - APIKeys: make(map[string]string), - Log: logger.Default(), + Log: logger.Default(), }, Safety: &SafetyLayer{ Perm: NewPermissionEngine(), @@ -281,9 +277,7 @@ func NewSessionServices(opts ...ServiceOption) *SessionServices { Intel: &Intelligence{ Beliefs: NewBeliefState(), }, - Optim: &Optimizer{ - Router: modelPkg.NewRouter(modelPkg.StrategyBalanced), - }, + Optim: &Optimizer{}, Observe: &Observability{ Tracer: oteltrace.NewTracer(), Metrics: metrics.NewRegistry(), @@ -317,7 +311,6 @@ func (s *Session) Services() *SessionServices { Messages: s.Persistence().RawMessages(), Provider: s.provider, Model: s.model, - APIKeys: s.apiKeys, System: s.Persistence().System(), Log: s.log, MaxTurns: s.LifecycleSvc().Limits().MaxTurns(), @@ -341,7 +334,6 @@ func (s *Session) Services() *SessionServices { Cost: Cost{Model: s.Cost.Model, PromptTokens: s.Cost.PromptTokens, CompletionTokens: s.Cost.CompletionTokens, TotalCostUSD: s.Cost.TotalCostUSD}, CostTracker: s.CostTracker, Cascade: s.LifecycleSvc().Cascade(), - Router: s.ChatLLM().Router(), MaxBudget: s.LifecycleSvc().Limits().MaxBudgetUSD(), }, Observe: &Observability{ @@ -349,15 +341,15 @@ func (s *Session) Services() *SessionServices { Metrics: s.metrics, Log: s.log, }, - Lifecycle: s.LifecycleSvc().Lifecycle(), - Reflector: s.LifecycleSvc().Reflector(), - Critic: s.LifecycleSvc().Critic(), - Backtrack: s.LifecycleSvc().Backtrack(), - Shadow: s.LifecycleSvc().Shadow(), - ConvoDAG: s.Persistence().DAG(), - Plan: s.Plan, - Teach: s.Teach, - Trajectory: s.Trajectory, - Snapshots: s.Snapshots, + Lifecycle: s.LifecycleSvc().Lifecycle(), + Reflector: s.LifecycleSvc().Reflector(), + Critic: s.LifecycleSvc().Critic(), + Backtrack: s.LifecycleSvc().Backtrack(), + Shadow: s.LifecycleSvc().Shadow(), + ConversationGraph: s.Persistence().Graph(), + Plan: s.Plan, + Teach: s.Teach, + Trajectory: s.Trajectory, + Snapshots: s.Snapshots, } } diff --git a/internal/engine/session_services_test.go b/internal/engine/session_services_test.go index e93a0a86..34b51a1d 100644 --- a/internal/engine/session_services_test.go +++ b/internal/engine/session_services_test.go @@ -20,9 +20,6 @@ func TestNewSessionServices_Defaults(t *testing.T) { if ss.Core == nil { t.Fatal("Core should not be nil with defaults") } - if ss.Core.APIKeys == nil { - t.Error("Core.APIKeys should be initialized") - } if ss.Core.Log == nil { t.Error("Core.Log should default to logger.Default()") } @@ -47,9 +44,6 @@ func TestNewSessionServices_Defaults(t *testing.T) { if ss.Optim == nil { t.Fatal("Optim should not be nil with defaults") } - if ss.Optim.Router == nil { - t.Error("Optim.Router should be initialized") - } if ss.Observe == nil { t.Fatal("Observe should not be nil with defaults") @@ -227,9 +221,6 @@ func TestSession_Services_Bridge(t *testing.T) { if svc.Optim.MaxBudget != 3.50 { t.Errorf("Optim.MaxBudget: expected 3.50, got %f", svc.Optim.MaxBudget) } - if svc.Optim.Router != s.ChatLLM().Router() { - t.Error("Optim.Router should reference same Router") - } if svc.Optim.Cascade != s.LifecycleSvc().Cascade() { t.Error("Optim.Cascade should reference same CascadeRouter") } @@ -265,8 +256,8 @@ func TestSession_Services_NilAdvancedFeatures(t *testing.T) { if svc.Shadow != nil { t.Error("Shadow should be nil when not set") } - if svc.ConvoDAG != nil { - t.Error("ConvoDAG should be nil when not set") + if svc.ConversationGraph != nil { + t.Error("ConversationGraph should be nil when not set") } if svc.Plan != nil { t.Error("Plan should be nil when not set") diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 699cc476..0804cb0d 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -7,6 +7,7 @@ import ( "strings" "time" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/engine/branching" @@ -334,14 +335,6 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } } - // Circuit breaker: select provider with failover (legacy single-provider clients only). - if s.ChatLLM().Router() != nil && !s.ChatLLM().DeploymentRouting() { - if selectedProvider, err := s.ChatLLM().Router().SelectProvider(s.provider); err == nil && selectedProvider != s.provider { - s.log.Info("provider failover", map[string]interface{}{"from": s.provider, "to": selectedProvider}) - opts.Provider = selectedProvider - } - } - // Count actual input tokens for precise budget tracking inputTokens := 0 for _, msg := range s.Persistence().RawMessages() { @@ -369,11 +362,10 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // Issue the LLM call via the ChatService. The service handles // rate limit, retry, and emergency compact internally; the // api.requests counter is incremented inside ChatService.Stream. - // We keep the apiDuration timer + circuit-breaker recording here - // at the Session level so we can feed the real latency to - // Router.RecordSuccess (the service has no start-time argument - // and deliberately stays out of circuit-breaker accounting). + // Hawk records product-level latency; provider health and circuit + // breaking are owned by Eyrie's routed transport. apiStart := time.Now() + managesResilience := clientManagesResilience(s.ChatLLM().Client()) result, err := s.ChatLLM().Stream(ctx, s.Persistence().RawMessages(), opts) apiDuration := time.Since(apiStart) s.metrics.Timer("api.latency").Record(apiDuration) @@ -384,10 +376,6 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { if loopSpan != nil { oteltrace.EndSpanWithError(loopSpan, err) } - // Record failure for circuit breaker (legacy single-provider clients only) - if s.ChatLLM().Router() != nil && !s.ChatLLM().DeploymentRouting() { - s.ChatLLM().Router().RecordFailure(s.provider, err) - } s.log.Error("stream error", map[string]interface{}{ "error": err.Error(), }) @@ -395,19 +383,17 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { return } - // Record success for circuit breaker (legacy single-provider clients only) - if s.ChatLLM().Router() != nil && !s.ChatLLM().DeploymentRouting() { - s.ChatLLM().Router().RecordSuccess(s.provider, apiDuration) - } - var textContent strings.Builder var toolCalls []types.ToolCall var stopReason string var lastUsage *types.EyrieUsage + var usageLedger streamUsageLedger + resolvedProvider := strings.TrimSpace(s.provider) + resolvedModel := strings.TrimSpace(activeModel) - // Streaming with retry for transient stream errors. Reasoning-only - // responses recover via non-streaming Chat (OpenCode Go / MiniMax) instead - // of repeating the same broken stream. + // Compatibility clients retain Hawk's historical stream retry and + // reasoning-only recovery. Eyrie facade clients already normalize and + // recover provider streams, so Hawk must consume their result exactly once. const maxStreamRetries = 2 var streamErr error var sawThinking bool @@ -425,6 +411,19 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { break eventLoop } switch ev.Type { + case "route_selected", "route_changed": + var changed bool + resolvedProvider, resolvedModel, changed = updateResolvedRoute(resolvedProvider, resolvedModel, ev.Route) + if changed { + s.log.Info("engine route selected", map[string]interface{}{ + "provider": resolvedProvider, + "model": resolvedModel, + }) + } + if loopSpan != nil { + loopSpan.SetTag("provider", resolvedProvider) + loopSpan.SetTag("model", resolvedModel) + } case "content": textContent.WriteString(ev.Content) ch <- StreamEvent{Type: "content", Content: ev.Content} @@ -440,7 +439,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { case "usage": if ev.Usage != nil { lastUsage = ev.Usage - s.recordStreamUsage(ch, ev.Usage.PromptTokens, ev.Usage.CompletionTokens, activeModel, taskType, apiStart) + if usageLedger.shouldRecord(ev.Usage, false) { + s.recordStreamUsage(ch, ev.Usage.PromptTokens, ev.Usage.CompletionTokens, resolvedProvider, resolvedModel, taskType, apiStart) + } } case "error": streamErr = fmt.Errorf("%s", ev.Error) @@ -454,14 +455,21 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { if ev.StopReason != "" { stopReason = ev.StopReason } + if ev.Usage != nil { + lastUsage = ev.Usage + if usageLedger.shouldRecord(ev.Usage, true) { + s.recordStreamUsage(ch, ev.Usage.PromptTokens, ev.Usage.CompletionTokens, resolvedProvider, resolvedModel, taskType, apiStart) + } + } } } } result.Close() thinkingOnly := streamErr == nil && textContent.Len() == 0 && len(toolCalls) == 0 && sawThinking - if thinkingOnly { + if thinkingOnly && !managesResilience { if resp, chatErr := s.ChatLLM().Chat(ctx, s.Persistence().RawMessages(), opts); chatErr == nil && resp != nil && strings.TrimSpace(resp.Content) != "" { + resolvedProvider, resolvedModel, _ = updateResolvedRoute(resolvedProvider, resolvedModel, resp.Route) content := resp.Content textContent.WriteString(content) ch <- StreamEvent{Type: "content", Content: content} @@ -471,6 +479,10 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { if resp.FinishReason != "" { stopReason = resp.FinishReason } + if resp.Usage != nil { + lastUsage = resp.Usage + s.recordStreamUsage(ch, resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resolvedProvider, resolvedModel, taskType, apiStart) + } streamErr = nil break } @@ -479,7 +491,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { return } - shouldRetry := streamErr != nil && isRetryableStreamError(streamErr) + shouldRetry := !managesResilience && streamErr != nil && isRetryableStreamError(streamErr) if !shouldRetry { break } @@ -520,14 +532,19 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { toolCalls = nil stopReason = "" lastUsage = nil + usageLedger.reset() streamErr = nil } + if streamErr != nil { + ch <- StreamEvent{Type: "error", Content: streamErr.Error()} + return + } // Providers like OpenCode Go often omit stream usage; estimate so billing footer updates. if lastUsage == nil && (textContent.Len() > 0 || len(toolCalls) > 0) { completionEst := estimateStreamCompletionTokens(textContent.String(), toolCalls) if inputTokens > 0 || completionEst > 0 { - s.recordStreamUsage(ch, inputTokens, completionEst, activeModel, taskType, apiStart) + s.recordStreamUsage(ch, inputTokens, completionEst, resolvedProvider, resolvedModel, taskType, apiStart) lastUsage = &types.EyrieUsage{ PromptTokens: inputTokens, CompletionTokens: completionEst, @@ -556,7 +573,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // text): Moonshot/kimi <|tool_calls_section_begin|> or Hermes/Nous // (Qwen and most OpenAI-compatible local models). if len(toolCalls) == 0 && (strings.Contains(textContent.String(), "<|tool_calls_section_begin|>") || strings.Contains(textContent.String(), "")) { - cleanText, inlineCalls := types.ParseInlineToolCalls(textContent.String()) + cleanText, engineCalls := eyrieengine.ParseInlineToolCalls(textContent.String()) + inlineCalls := make([]types.ToolCall, 0, len(engineCalls)) + for _, call := range engineCalls { + inlineCalls = append(inlineCalls, types.ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) + } if len(inlineCalls) > 0 { textContent.Reset() textContent.WriteString(cleanText) @@ -576,8 +597,8 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // Post-query hook hooks.ExecuteAsync(ctx, hooks.EventPostQuery, map[string]interface{}{ - "provider": s.provider, - "model": s.model, + "provider": resolvedProvider, + "model": resolvedModel, "content": textContent.String(), "tools": len(toolCalls), }) @@ -595,25 +616,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } } - // Handle max_tokens recovery. - // - // Two strategies coexist: - // 1. Engine-level (this block): when no tool calls, append the - // partial assistant text and a 'Continue from where you left - // off.' user turn, then loop. Cheap (single retry) but - // pollutes the conversation with a synthetic user message. - // 2. Client-level (eyrie/client.StreamChatWithContinuation, - // deprecated in eyrie v0.3.0): handles max_tokens even with - // tool calls by recursing StreamChat internally. Cleaner - // conversation but appends a synthetic 'Continue.' user - // turn too. - // - // Both still produce a synthetic user message; the eyrie - // conversation engine (OutputGroupID-based continuation) avoids - // this but is not what hawk's agent loop uses today. A future - // refactor could port hawk to eyrie/conversation.Engine.Prompt - // and drop the synthetic user message entirely. - if stopReason == "max_tokens" && len(toolCalls) == 0 && recoveryCount < maxRecoveryRetries { + // Compatibility-only max_tokens recovery. Eyrie's engine facade owns + // continuation and exposes one normalized stream to Hawk. Legacy clients + // retain the historical synthetic turn so injected integrations do not + // change behavior while they migrate to the facade. + if !managesResilience && stopReason == "max_tokens" && len(toolCalls) == 0 && recoveryCount < maxRecoveryRetries { recoveryCount++ s.Persistence().SetRawMessages(append(s.Persistence().RawMessages(), types.EyrieMessage{Role: "assistant", Content: textContent.String()})) s.Persistence().SetRawMessages(append(s.Persistence().RawMessages(), types.EyrieMessage{Role: "user", Content: "Continue from where you left off."})) diff --git a/internal/engine/stream_usage.go b/internal/engine/stream_usage.go index b718977e..fb621c2c 100644 --- a/internal/engine/stream_usage.go +++ b/internal/engine/stream_usage.go @@ -10,20 +10,72 @@ import ( analytics "github.com/GrayCodeAI/hawk/internal/observability" ) +// streamUsageLedger prevents providers that repeat their final usage on the +// done event from being billed twice. Distinct usage events are preserved so +// multi-request continuation streams still account for every provider call. +type streamUsageLedger struct { + lastUsageEvent *types.EyrieUsage +} + +func (l *streamUsageLedger) shouldRecord(usage *types.EyrieUsage, terminal bool) bool { + if usage == nil { + return false + } + if terminal { + duplicate := equalStreamUsage(l.lastUsageEvent, usage) + l.lastUsageEvent = nil + return !duplicate + } + l.lastUsageEvent = cloneStreamUsage(usage) + return true +} + +func (l *streamUsageLedger) reset() { + l.lastUsageEvent = nil +} + +func equalStreamUsage(a, b *types.EyrieUsage) bool { + if a == nil || b == nil { + return false + } + return *a == *b +} + +func cloneStreamUsage(usage *types.EyrieUsage) *types.EyrieUsage { + if usage == nil { + return nil + } + cloned := *usage + return &cloned +} + +func updateResolvedRoute(provider, model string, route *types.ResolvedRoute) (string, string, bool) { + if route == nil { + return provider, model, false + } + nextProvider := strings.TrimSpace(route.Provider) + nextModel := strings.TrimSpace(route.Model) + if nextProvider == "" { + nextProvider = provider + } + if nextModel == "" { + nextModel = model + } + changed := nextProvider != provider || nextModel != model + return nextProvider, nextModel, changed +} + // recordStreamUsage updates session billing/context counters and notifies the TUI. -func (s *Session) recordStreamUsage(ch chan<- StreamEvent, prompt, completion int, model, taskType string, apiStart time.Time) { +// Provider and model are the concrete route selected by Eyrie, not merely the +// user's preference, so fallback usage is attributed to the transport that ran. +func (s *Session) recordStreamUsage(ch chan<- StreamEvent, prompt, completion int, provider, model, taskType string, apiStart time.Time) { if prompt <= 0 && completion <= 0 { return } - if model != "" { - s.mu.Lock() - if strings.TrimSpace(s.Cost.Model) == "" { - s.Cost.Model = model - } - s.mu.Unlock() - } + provider = strings.TrimSpace(provider) + model = strings.TrimSpace(model) s.RecordAPIUsage(prompt, completion) - s.Cost.Add(prompt, completion) + s.Cost.AddForModel(model, prompt, completion) if s.CostTracker != nil && model != "" { inPrice, outPrice := ModelPricing(model) cost := float64(prompt)*inPrice/1_000_000 + float64(completion)*outPrice/1_000_000 @@ -43,6 +95,8 @@ func (s *Session) recordStreamUsage(ch chan<- StreamEvent, prompt, completion in Usage: &StreamUsage{ PromptTokens: prompt, CompletionTokens: completion, + Provider: provider, + Model: model, }, } } diff --git a/internal/engine/stream_usage_test.go b/internal/engine/stream_usage_test.go new file mode 100644 index 00000000..8a5e739c --- /dev/null +++ b/internal/engine/stream_usage_test.go @@ -0,0 +1,72 @@ +package engine + +import ( + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestStreamUsageLedgerRecordsDoneOnlyUsage(t *testing.T) { + var ledger streamUsageLedger + terminal := &types.EyrieUsage{PromptTokens: 13, CompletionTokens: 7, TotalTokens: 20} + + if !ledger.shouldRecord(terminal, true) { + t.Fatal("done-only usage was dropped") + } +} + +func TestStreamUsageLedgerDoesNotDoubleCountRepeatedDoneUsage(t *testing.T) { + var ledger streamUsageLedger + usage := &types.EyrieUsage{PromptTokens: 13, CompletionTokens: 7, TotalTokens: 20, CacheReadTokens: 4} + + if !ledger.shouldRecord(usage, false) { + t.Fatal("usage event was dropped") + } + if ledger.shouldRecord(cloneStreamUsage(usage), true) { + t.Fatal("identical terminal usage would be double-counted") + } +} + +func TestStreamUsageLedgerPreservesDistinctContinuationUsage(t *testing.T) { + var ledger streamUsageLedger + first := &types.EyrieUsage{PromptTokens: 13, CompletionTokens: 7, TotalTokens: 20} + second := &types.EyrieUsage{PromptTokens: 20, CompletionTokens: 5, TotalTokens: 25} + + if !ledger.shouldRecord(first, false) || !ledger.shouldRecord(second, false) { + t.Fatal("distinct continuation usage was dropped") + } + if ledger.shouldRecord(cloneStreamUsage(second), true) { + t.Fatal("terminal repeat of the last continuation usage would be double-counted") + } +} + +func TestUpdateResolvedRoutePreservesMissingFields(t *testing.T) { + provider, model, changed := updateResolvedRoute("openai", "openai/gpt-5", &types.ResolvedRoute{Model: "openai/gpt-5-mini"}) + if !changed || provider != "openai" || model != "openai/gpt-5-mini" { + t.Fatalf("resolved route = %q/%q changed=%v", provider, model, changed) + } +} + +func TestRecordStreamUsageAttributesResolvedRoute(t *testing.T) { + const ( + resolvedProvider = "openai" + resolvedModel = "openai/fallback-test-model" + ) + RegisterLivePricing(resolvedModel, 2, 4) + sess := NewSessionWithClient(NewMockClientForTest(), "anthropic", "anthropic/requested-test-model", "", nil, true) + ch := make(chan StreamEvent, 1) + + sess.recordStreamUsage(ch, 1_000_000, 1_000_000, resolvedProvider, resolvedModel, "test", time.Now()) + + if sess.Cost.Model != resolvedModel { + t.Fatalf("cost model = %q, want resolved model %q", sess.Cost.Model, resolvedModel) + } + if got := sess.Cost.Total(); got != 6 { + t.Fatalf("resolved-route cost = %v, want 6", got) + } + event := <-ch + if event.Usage == nil || event.Usage.Provider != resolvedProvider || event.Usage.Model != resolvedModel { + t.Fatalf("usage event lost resolved route: %+v", event.Usage) + } +} diff --git a/internal/engine/subagent_synthesis_test.go b/internal/engine/subagent_synthesis_test.go index f038e289..8a309831 100644 --- a/internal/engine/subagent_synthesis_test.go +++ b/internal/engine/subagent_synthesis_test.go @@ -2,115 +2,82 @@ package engine import ( "context" + "errors" "testing" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/hawk/internal/types" ) func TestSynthesizeSubAgent(t *testing.T) { t.Run("tools disabled and prompt appended", func(t *testing.T) { - mock := client.NewMockProvider(client.MockModeFixed) - mock.Response = "Here is my summary of findings." - - mockClient := &mockLLMForSynthesis{provider: mock} - + mock := &synthesisMockClient{response: "Here is my summary of findings."} conversation := []types.EyrieMessage{ {Role: "user", Content: "Find all Go files with errors"}, {Role: "assistant", Content: "I'll search for Go files."}, } - result, err := SynthesizeSubAgent(context.Background(), mockClient, "test-model", conversation) + result, err := SynthesizeSubAgent(context.Background(), mock, "test-model", conversation) if err != nil { t.Fatalf("unexpected error: %v", err) } - - if result != "Here is my summary of findings." { + if result != mock.response { t.Errorf("expected summary text, got: %q", result) } - - // Verify the mock was called exactly once. - if mock.CallCount() != 1 { - t.Fatalf("expected 1 call, got %d", mock.CallCount()) - } - - call := mock.LastCall() - - // Verify tools are nil (disabled). - if call.Options.Tools != nil { - t.Errorf("expected Tools to be nil, got: %v", call.Options.Tools) + if mock.calls != 1 { + t.Fatalf("expected 1 call, got %d", mock.calls) } - - // Verify synthesis prompt was appended as last user message. - lastMsg := call.Messages[len(call.Messages)-1] - if lastMsg.Role != "user" { - t.Errorf("expected last message role 'user', got: %q", lastMsg.Role) + if mock.options.Tools != nil { + t.Errorf("expected Tools to be nil, got: %v", mock.options.Tools) } - if lastMsg.Content != SynthesisPrompt { - t.Errorf("expected SynthesisPrompt in last message, got: %q", lastMsg.Content) - } - - // Verify conversation messages are preserved before the synthesis prompt. - if len(call.Messages) != 3 { - t.Fatalf("expected 3 messages (2 conversation + 1 prompt), got %d", len(call.Messages)) + if len(mock.messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(mock.messages)) } - if call.Messages[0].Content != "Find all Go files with errors" { - t.Errorf("first message not preserved: %q", call.Messages[0].Content) + last := mock.messages[len(mock.messages)-1] + if last.Role != "user" || last.Content != SynthesisPrompt { + t.Fatalf("last message = %#v, want synthesis prompt", last) } - if call.Messages[1].Content != "I'll search for Go files." { - t.Errorf("second message not preserved: %q", call.Messages[1].Content) + if mock.messages[0].Content != conversation[0].Content || mock.messages[1].Content != conversation[1].Content { + t.Fatalf("conversation was not preserved: %#v", mock.messages) } }) t.Run("nil client returns error", func(t *testing.T) { - conversation := []types.EyrieMessage{ - {Role: "user", Content: "test"}, - } - _, err := SynthesizeSubAgent(context.Background(), nil, "model", conversation) + _, err := SynthesizeSubAgent(context.Background(), nil, "model", []types.EyrieMessage{{Role: "user", Content: "test"}}) if err == nil { t.Fatal("expected error for nil client") } }) t.Run("provider error propagated", func(t *testing.T) { - mock := client.NewMockProvider(client.MockModeError) - mockClient := &mockLLMForSynthesis{provider: mock} - - conversation := []types.EyrieMessage{ - {Role: "user", Content: "test"}, - } - - _, err := SynthesizeSubAgent(context.Background(), mockClient, "model", conversation) + mock := &synthesisMockClient{err: errors.New("provider unavailable")} + _, err := SynthesizeSubAgent(context.Background(), mock, "model", []types.EyrieMessage{{Role: "user", Content: "test"}}) if err == nil { t.Fatal("expected error from provider") } }) t.Run("empty response returns error", func(t *testing.T) { - mock := client.NewMockProvider(client.MockModeFixed) - mock.Response = "" - mockClient := &mockLLMForSynthesis{provider: mock} - - conversation := []types.EyrieMessage{ - {Role: "user", Content: "test"}, - } - - _, err := SynthesizeSubAgent(context.Background(), mockClient, "model", conversation) + _, err := SynthesizeSubAgent(context.Background(), &synthesisMockClient{}, "model", []types.EyrieMessage{{Role: "user", Content: "test"}}) if err == nil { t.Fatal("expected error for empty response") } }) } -// mockLLMForSynthesis wraps a MockProvider to satisfy the LLMClient interface. -type mockLLMForSynthesis struct { - provider *client.MockProvider +type synthesisMockClient struct { + response string + err error + calls int + messages []types.EyrieMessage + options types.ChatOptions } -func (m *mockLLMForSynthesis) Chat(ctx context.Context, msgs []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { - resp, err := m.provider.Chat(ctx, types.ToClientMessages(msgs), types.ToClientChatOptions(opts)) - if err != nil { - return nil, err +func (m *synthesisMockClient) Chat(_ context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + m.calls++ + m.messages = append([]types.EyrieMessage(nil), messages...) + m.options = opts + if m.err != nil { + return nil, m.err } - return types.FromClientResponse(resp), nil + return &types.EyrieResponse{Content: m.response, FinishReason: "end_turn"}, nil } diff --git a/internal/engine/token/token_predictor_test.go b/internal/engine/token/token_predictor_test.go index 7c642af8..5ab03c27 100644 --- a/internal/engine/token/token_predictor_test.go +++ b/internal/engine/token/token_predictor_test.go @@ -5,7 +5,6 @@ import ( "sync" "testing" - eycatalog "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/hawk/internal/provider/routing" ) @@ -13,9 +12,9 @@ const testProvider = "anthropic" func testTierModels(t *testing.T, provider string) (haiku, sonnet, opus string) { t.Helper() - haiku = routing.PreferredModelForTier(provider, eycatalog.TierHaiku, "") - sonnet = routing.PreferredModelForTier(provider, eycatalog.TierSonnet, "") - opus = routing.PreferredModelForTier(provider, eycatalog.TierOpus, "") + haiku = routing.PreferredModelForTier(provider, routing.TierHaiku, "") + sonnet = routing.PreferredModelForTier(provider, routing.TierSonnet, "") + opus = routing.PreferredModelForTier(provider, routing.TierOpus, "") if haiku == "" || sonnet == "" || opus == "" { t.Skipf("no tier models for %q without live catalog (fully dynamic)", provider) } diff --git a/internal/engine/vision.go b/internal/engine/vision.go index acc62e5e..a74c0cdf 100644 --- a/internal/engine/vision.go +++ b/internal/engine/vision.go @@ -1,7 +1,6 @@ package engine import ( - "context" "strings" "github.com/GrayCodeAI/hawk/internal/types" @@ -98,12 +97,12 @@ func (s *Session) AddUserWithAttachment(content, imageBase64, mediaType string) })) s.mu.Unlock() - if s.Persistence().DAG() != nil { + if s.Persistence().Graph() != nil { parentID := "" - if head, err := s.Persistence().DAG().Head(context.Background()); err == nil && head != nil { + if head, err := s.Persistence().Graph().Head(); err == nil && head != nil { parentID = head.ID } - _, _ = s.Persistence().DAG().Append(context.Background(), parentID, "user", content+" [image attached]") + _, _ = s.Persistence().Graph().Append(parentID, "user", content+" [image attached]") } return true } diff --git a/internal/multiagent/worker.go b/internal/multiagent/worker.go index a5e189d8..d954dda7 100644 --- a/internal/multiagent/worker.go +++ b/internal/multiagent/worker.go @@ -7,7 +7,7 @@ import ( "os/exec" "strings" - "github.com/GrayCodeAI/eyrie/runtime" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/tool" ) @@ -37,7 +37,7 @@ func EngineWorker(provider, model, systemPrompt string) WorkerFunc { // Create engine session with tools registry := tool.NewRegistry(baseWorkerTools()...) - selection := runtime.EffectiveSelection(ctx, runtime.SelectionOpts{ + selection := hawkconfig.EffectiveSelection(ctx, hawkconfig.SelectionOptions{ ProviderOverride: provider, ModelOverride: model, }) @@ -149,7 +149,7 @@ func ReadOnlyValidationWorker(provider, model, systemPrompt string) WorkerFunc { ) registry := tool.NewRegistry(readOnlyWorkerTools()...) - selection := runtime.EffectiveSelection(ctx, runtime.SelectionOpts{ + selection := hawkconfig.EffectiveSelection(ctx, hawkconfig.SelectionOptions{ ProviderOverride: provider, ModelOverride: model, }) diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go index 382623a7..fc835d23 100644 --- a/internal/onboarding/onboarding.go +++ b/internal/onboarding/onboarding.go @@ -7,7 +7,6 @@ import ( "os" "strings" - "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/mattn/go-runewidth" "golang.org/x/term" @@ -88,13 +87,11 @@ func NeedsSetup() bool { // RunSetup runs the interactive first-run setup. func RunSetup() error { - hawkconfig.PrepareCredentialDiscovery(context.Background()) - reader := bufio.NewReader(os.Stdin) fmt.Println(teal + bold + " Developer setup" + reset) fmt.Println() - fmt.Println(dim + " Keys are stored in " + credentials.PlatformSecretStoreName() + ", not .env or shell env." + reset) + fmt.Println(dim + " Keys are stored in " + hawkconfig.CredentialStoreName() + ", not .env or shell env." + reset) fmt.Println() // Provider selection @@ -148,7 +145,7 @@ func RunSetup() error { fmt.Printf(" Selected: %s%s%s\n", teal, selected.name, reset) // API key input - if selected.envKey != "" && !credentials.HasSecret(context.Background(), selected.envKey) { + if selected.envKey != "" && !hawkconfig.HasStoredCredentialForProvider(context.Background(), selected.name) { fmt.Println() fmt.Printf(" Enter your %s API key:\n", selected.name) fmt.Printf(" %s(Get one at the provider's website)%s\n", dim, reset) @@ -182,13 +179,13 @@ func RunSetup() error { } fmt.Println() - fmt.Printf(" %s"+icons.CheckBold()+" API key saved to %s%s\n", teal, credentials.PlatformSecretStoreName(), reset) + fmt.Printf(" %s"+icons.CheckBold()+" API key saved to %s%s\n", teal, hawkconfig.CredentialStoreName(), reset) } else if selected.name == "ollama" { _ = hawkconfig.SetActiveProvider(context.Background(), "ollama") fmt.Printf(" %s"+icons.CheckBold()+" Ollama selected (make sure ollama is running)%s\n", teal, reset) } else { _ = hawkconfig.SetActiveProvider(context.Background(), selected.name) - fmt.Printf(" %s"+icons.CheckBold()+" Using %s (credential already in %s)%s\n", teal, selected.name, credentials.PlatformSecretStoreName(), reset) + fmt.Printf(" %s"+icons.CheckBold()+" Using %s (credential already in %s)%s\n", teal, selected.name, hawkconfig.CredentialStoreName(), reset) } // Security notes diff --git a/internal/provider/routing/catalog.go b/internal/provider/routing/catalog.go index c99376ce..cdb33958 100644 --- a/internal/provider/routing/catalog.go +++ b/internal/provider/routing/catalog.go @@ -1,6 +1,6 @@ -// Package routing provides model routing and health checking. -// Model discovery, pricing, and catalog data are delegated to eyrie. -// Hawk does NOT carry a hardcoded model catalog. +// Package routing provides Hawk-owned task routing and health policy. Model +// discovery, pricing, provider ownership, and catalog policy are delegated to +// Eyrie's host-neutral engine facade. package routing import ( @@ -8,10 +8,10 @@ import ( "sort" "sync" - "github.com/GrayCodeAI/eyrie/catalog" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) -// ModelInfo describes a known LLM model (view over eyrie catalog entries). +// ModelInfo is Hawk's product-facing view of Eyrie model metadata. type ModelInfo struct { Name string `json:"name"` Provider string `json:"provider"` @@ -23,78 +23,57 @@ type ModelInfo struct { } var ( - catalogOnce sync.Once - cachedCatalog *catalog.CompiledCatalog + modelEngineOnce sync.Once + modelEngine *eyrieengine.Engine ) -func eyrieCatalog() *catalog.CompiledCatalog { - catalogOnce.Do(func() { - compiled, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{ - CachePath: catalog.DefaultCachePath(), - RequireCache: false, - }) - if err != nil { - return - } - cachedCatalog = compiled +func eyrieModelEngine() *eyrieengine.Engine { + modelEngineOnce.Do(func() { + modelEngine, _ = eyrieengine.New(eyrieengine.Options{}) }) - return cachedCatalog + return modelEngine } -func fromEyrie(model catalog.Model, offering catalog.ModelOffering) ModelInfo { - inPrice, outPrice := 0.0, 0.0 - if offering.Pricing.RatesPer1M != nil { - inPrice = offering.Pricing.RatesPer1M["input_tokens"] - outPrice = offering.Pricing.RatesPer1M["output_tokens"] - } +func fromEngineModel(model eyrieengine.Model) ModelInfo { return ModelInfo{ - Name: model.ID, - Provider: model.ProviderID, + Name: model.ID, Provider: model.ProviderID, ContextSize: model.ContextWindow, - InputPrice: inPrice, - OutputPrice: outPrice, - Description: model.Name, - } -} - -func fromEyrieEntry(entry catalog.ModelCatalogEntry, provider string) ModelInfo { - return ModelInfo{ - Name: entry.ID, - Provider: provider, - ContextSize: entry.ContextWindow, - InputPrice: entry.InputPricePer1M, - OutputPrice: entry.OutputPricePer1M, - Description: entry.DisplayName, + InputPrice: model.InputPricePer1M, OutputPrice: model.OutputPricePer1M, + Description: model.Description, } } -// Find looks up a model by name via eyrie's JSON catalog. +// Find looks up a model by id or alias through Eyrie. func Find(name string) (ModelInfo, bool) { - if compiled := eyrieCatalog(); compiled != nil { - if canonical, ok := compiled.CanonicalModelForAliasOrID(name); ok { - model := compiled.ModelsByID[canonical] - offering := firstOffering(compiled, canonical, "") - return fromEyrie(model, offering), true - } + engine := eyrieModelEngine() + if engine == nil { + return ModelInfo{}, false + } + model, ok, err := engine.ModelInfo(context.Background(), name) + if err != nil || !ok { + return ModelInfo{}, false } - return ModelInfo{}, false + return fromEngineModel(model), true } -// ByProvider returns all models for a given provider from eyrie's catalog. +// ByProvider returns all models served by a provider/gateway. func ByProvider(provider string) []ModelInfo { - provider = catalog.CanonicalProviderID(provider) - compiled := eyrieCatalog() - out := []ModelInfo{} - if compiled != nil { - entries := catalog.ModelEntriesForProvider(compiled, provider) - for _, entry := range entries { - out = append(out, fromEyrieEntry(entry, provider)) - } + engine := eyrieModelEngine() + if engine == nil { + return nil + } + models, err := engine.ListModels(context.Background(), provider, false) + if err != nil { + return nil + } + out := make([]ModelInfo, 0, len(models)) + for _, model := range models { + out = append(out, fromEngineModel(model)) } return out } -// Recommended returns the first catalog model for a provider. +// Recommended returns the default catalog model for a provider. func Recommended(provider string) (ModelInfo, bool) { name := DefaultModel(provider) if name == "" { @@ -107,36 +86,26 @@ func Recommended(provider string) (ModelInfo, bool) { return info, ok } -// DefaultModel returns the first catalog model for a provider via eyrie JSON. func DefaultModel(provider string) string { - return catalog.ProviderDefaultModel(eyrieCatalog(), provider, "") + if engine := eyrieModelEngine(); engine != nil { + return engine.DefaultModel(context.Background(), provider, "") + } + return "" } -// AllProviders returns all canonical model owner providers from eyrie's catalog. func AllProviders() []string { - out := catalog.AllModelProviders(eyrieCatalog()) - sort.Strings(out) - return out -} - -func firstOffering(compiled *catalog.CompiledCatalog, canonicalModelID, deploymentID string) catalog.ModelOffering { - offerings := compiled.OfferingsByCanonicalModel[canonicalModelID] - if len(offerings) == 0 { - return catalog.ModelOffering{} + engine := eyrieModelEngine() + if engine == nil { + return nil } - if deploymentID != "" { - for _, offering := range offerings { - if offering.DeploymentID == deploymentID { - return offering - } - } + providers, err := engine.ModelProviders(context.Background()) + if err != nil { + return nil } - sort.SliceStable(offerings, func(i, j int) bool { - return offerings[i].DeploymentID < offerings[j].DeploymentID - }) - return offerings[0] + sort.Strings(providers) + return providers } func canonicalProvider(provider string) string { - return catalog.CanonicalProviderID(provider) + return eyrieengine.NormalizeProviderID(provider) } diff --git a/internal/provider/routing/fallback.go b/internal/provider/routing/fallback.go deleted file mode 100644 index 5ed32133..00000000 --- a/internal/provider/routing/fallback.go +++ /dev/null @@ -1,370 +0,0 @@ -package routing - -import ( - "fmt" - "sort" - "strings" - "sync" - "time" -) - -// FallbackProviderHealth tracks detailed health state of a provider in the fallback chain. -type FallbackProviderHealth struct { - Name string - Status string // "healthy", "degraded", "down" - LastSuccess time.Time - LastFailure time.Time - ConsecutiveFailures int - CooldownUntil *time.Time - Latency time.Duration -} - -// ProviderConfig describes a provider entry in the fallback chain. -type ProviderConfig struct { - Name string - Model string - Priority int - Weight float64 - MaxRetries int - CooldownDuration time.Duration -} - -// FallbackResult captures the outcome of a provider selection with fallback. -type FallbackResult struct { - Provider string - Model string - Attempts int - Fallbacks []string - Duration time.Duration -} - -// FallbackChain manages multi-provider fallback with health tracking. -type FallbackChain struct { - Providers []ProviderConfig - HealthStatus map[string]*FallbackProviderHealth - ActiveProvider string - mu sync.RWMutex -} - -const ( - statusHealthy = "healthy" - statusDegraded = "degraded" - statusDown = "down" - - degradedThreshold = 2 - downThreshold = 5 -) - -// NewFallbackChain creates a new fallback chain from the given provider configs. -// Providers are sorted by priority (lower number = higher priority). -func NewFallbackChain(providers []ProviderConfig) *FallbackChain { - sorted := make([]ProviderConfig, len(providers)) - copy(sorted, providers) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Priority < sorted[j].Priority - }) - - health := make(map[string]*FallbackProviderHealth, len(providers)) - for _, p := range sorted { - health[p.Name] = &FallbackProviderHealth{ - Name: p.Name, - Status: statusHealthy, - } - } - - active := "" - if len(sorted) > 0 { - active = sorted[0].Name - } - - return &FallbackChain{ - Providers: sorted, - HealthStatus: health, - ActiveProvider: active, - } -} - -// SelectProvider picks the highest-priority healthy provider, skipping those in cooldown. -func (fc *FallbackChain) SelectProvider() (*ProviderConfig, error) { - fc.mu.RLock() - defer fc.mu.RUnlock() - - now := time.Now() - for i := range fc.Providers { - p := &fc.Providers[i] - h, ok := fc.HealthStatus[p.Name] - if !ok { - continue - } - if h.Status == statusDown { - // Check if cooldown has expired - if h.CooldownUntil != nil && now.Before(*h.CooldownUntil) { - continue - } - } - if h.CooldownUntil != nil && now.Before(*h.CooldownUntil) { - continue - } - return p, nil - } - - return nil, fmt.Errorf("all providers are down or in cooldown") -} - -// RecordSuccess records a successful call to a provider and resets failure state. -func (fc *FallbackChain) RecordSuccess(provider string, latency time.Duration) { - fc.mu.Lock() - defer fc.mu.Unlock() - - h, ok := fc.HealthStatus[provider] - if !ok { - return - } - - h.ConsecutiveFailures = 0 - h.LastSuccess = time.Now() - h.Latency = latency - h.Status = statusHealthy - h.CooldownUntil = nil - fc.ActiveProvider = provider -} - -// RecordFailure records a failed call to a provider and applies cooldown if threshold exceeded. -func (fc *FallbackChain) RecordFailure(provider string, err error) { - fc.mu.Lock() - defer fc.mu.Unlock() - - h, ok := fc.HealthStatus[provider] - if !ok { - return - } - - h.ConsecutiveFailures++ - h.LastFailure = time.Now() - - switch { - case h.ConsecutiveFailures >= downThreshold: - h.Status = statusDown - cd := fc.cooldownFor(provider) - until := time.Now().Add(cd) - h.CooldownUntil = &until - case h.ConsecutiveFailures >= degradedThreshold: - h.Status = statusDegraded - } -} - -// GetFallback finds the next healthy provider after the current one in the chain. -func (fc *FallbackChain) GetFallback(currentProvider string) (*ProviderConfig, error) { - fc.mu.RLock() - defer fc.mu.RUnlock() - - now := time.Now() - found := false - for i := range fc.Providers { - p := &fc.Providers[i] - if p.Name == currentProvider { - found = true - continue - } - if !found { - continue - } - h, ok := fc.HealthStatus[p.Name] - if !ok { - continue - } - if h.CooldownUntil != nil && now.Before(*h.CooldownUntil) { - continue - } - if h.Status == statusDown { - if h.CooldownUntil == nil || now.Before(*h.CooldownUntil) { - continue - } - } - return p, nil - } - - // Wrap around: check providers before currentProvider - for i := range fc.Providers { - p := &fc.Providers[i] - if p.Name == currentProvider { - break - } - h, ok := fc.HealthStatus[p.Name] - if !ok { - continue - } - if h.CooldownUntil != nil && now.Before(*h.CooldownUntil) { - continue - } - if h.Status == statusDown { - if h.CooldownUntil == nil || now.Before(*h.CooldownUntil) { - continue - } - } - return p, nil - } - - return nil, fmt.Errorf("no fallback provider available") -} - -// IsHealthy returns true if the named provider is currently healthy and not in cooldown. -func (fc *FallbackChain) IsHealthy(provider string) bool { - fc.mu.RLock() - defer fc.mu.RUnlock() - - h, ok := fc.HealthStatus[provider] - if !ok { - return false - } - if h.CooldownUntil != nil && time.Now().Before(*h.CooldownUntil) { - return false - } - return h.Status == statusHealthy -} - -// FormatStatus returns a human-readable status report of all providers. -func (fc *FallbackChain) FormatStatus() string { - fc.mu.RLock() - defer fc.mu.RUnlock() - - var sb strings.Builder - sb.WriteString("Provider Status:\n") - sb.WriteString("─────────────────────────\n") - - for i, p := range fc.Providers { - h := fc.HealthStatus[p.Name] - statusTag := strings.ToUpper(h.Status) - line := fmt.Sprintf("%d. %s [%s]", i+1, p.Name, statusTag) - - switch h.Status { - case statusHealthy: - line += fmt.Sprintf(" latency: %s, priority: %d", formatDuration(h.Latency), p.Priority) - case statusDegraded: - line += fmt.Sprintf(" latency: %s, %d failures", formatDuration(h.Latency), h.ConsecutiveFailures) - case statusDown: - if h.CooldownUntil != nil { - line += fmt.Sprintf(" cooldown until %s (%d failures)", - h.CooldownUntil.Format("15:04"), h.ConsecutiveFailures) - } else { - line += fmt.Sprintf(" (%d failures)", h.ConsecutiveFailures) - } - } - - sb.WriteString(line) - sb.WriteString("\n") - _ = i - } - - sb.WriteString("\nActive: ") - sb.WriteString(fc.ActiveProvider) - sb.WriteString("\n") - - sb.WriteString("Fallback chain: ") - names := make([]string, len(fc.Providers)) - for i, p := range fc.Providers { - names[i] = p.Name - } - sb.WriteString(strings.Join(names, " → ")) - sb.WriteString("\n") - - return sb.String() -} - -// RecoverProvider manually marks a provider as healthy and clears its cooldown. -func (fc *FallbackChain) RecoverProvider(provider string) { - fc.mu.Lock() - defer fc.mu.Unlock() - - h, ok := fc.HealthStatus[provider] - if !ok { - return - } - - h.Status = statusHealthy - h.ConsecutiveFailures = 0 - h.CooldownUntil = nil -} - -// AllDown returns true if every provider is either down or in cooldown. -func (fc *FallbackChain) AllDown() bool { - fc.mu.RLock() - defer fc.mu.RUnlock() - - now := time.Now() - for _, p := range fc.Providers { - h, ok := fc.HealthStatus[p.Name] - if !ok { - continue - } - if h.Status != statusDown { - if h.CooldownUntil == nil || !now.Before(*h.CooldownUntil) { - return false - } - } else { - // Down but cooldown expired means it can be retried - if h.CooldownUntil != nil && !now.Before(*h.CooldownUntil) { - return false - } - if h.CooldownUntil == nil { - // Down with no cooldown is still considered unavailable - continue - } - } - } - return true -} - -// BestLatency returns the provider config with the lowest recent latency -// among healthy providers. Returns nil if no providers are healthy. -func (fc *FallbackChain) BestLatency() *ProviderConfig { - fc.mu.RLock() - defer fc.mu.RUnlock() - - var best *ProviderConfig - var bestLatency time.Duration - - for i := range fc.Providers { - p := &fc.Providers[i] - h, ok := fc.HealthStatus[p.Name] - if !ok { - continue - } - if h.Status == statusDown { - continue - } - if h.Latency == 0 { - continue - } - if best == nil || h.Latency < bestLatency { - best = p - bestLatency = h.Latency - } - } - - return best -} - -// cooldownFor returns the cooldown duration for a provider from its config. -func (fc *FallbackChain) cooldownFor(provider string) time.Duration { - for _, p := range fc.Providers { - if p.Name == provider { - if p.CooldownDuration > 0 { - return p.CooldownDuration - } - return 60 * time.Second // default cooldown - } - } - return 60 * time.Second -} - -// formatDuration formats a duration for display in status output. -func formatDuration(d time.Duration) string { - if d == 0 { - return "n/a" - } - if d < time.Second { - return fmt.Sprintf("%dms", d.Milliseconds()) - } - return fmt.Sprintf("%.1fs", d.Seconds()) -} diff --git a/internal/provider/routing/fallback_test.go b/internal/provider/routing/fallback_test.go deleted file mode 100644 index 0dc50fed..00000000 --- a/internal/provider/routing/fallback_test.go +++ /dev/null @@ -1,577 +0,0 @@ -package routing - -import ( - "fmt" - "strings" - "testing" - "time" -) - -func testProviders() []ProviderConfig { - return []ProviderConfig{ - { - Name: "anthropic", - Model: "claude-opus-4-20250514", - Priority: 1, - Weight: 1.0, - MaxRetries: 3, - CooldownDuration: 30 * time.Second, - }, - { - Name: "openai", - Model: "gpt-4o", - Priority: 2, - Weight: 0.8, - MaxRetries: 3, - CooldownDuration: 30 * time.Second, - }, - { - Name: "ollama", - Model: "llama3", - Priority: 3, - Weight: 0.5, - MaxRetries: 2, - CooldownDuration: 60 * time.Second, - }, - } -} - -func TestNewFallbackChain(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - if len(fc.Providers) != 3 { - t.Fatalf("expected 3 providers, got %d", len(fc.Providers)) - } - - // Should be sorted by priority - if fc.Providers[0].Name != "anthropic" { - t.Errorf("expected first provider to be anthropic, got %s", fc.Providers[0].Name) - } - if fc.Providers[1].Name != "openai" { - t.Errorf("expected second provider to be openai, got %s", fc.Providers[1].Name) - } - if fc.Providers[2].Name != "ollama" { - t.Errorf("expected third provider to be ollama, got %s", fc.Providers[2].Name) - } - - // All providers should be healthy - for _, p := range fc.Providers { - h := fc.HealthStatus[p.Name] - if h.Status != statusHealthy { - t.Errorf("provider %s should be healthy, got %s", p.Name, h.Status) - } - } - - // Active provider should be highest priority - if fc.ActiveProvider != "anthropic" { - t.Errorf("expected active provider to be anthropic, got %s", fc.ActiveProvider) - } -} - -func TestNewFallbackChainSortsByPriority(t *testing.T) { - // Provide in reverse order - providers := []ProviderConfig{ - {Name: "low", Priority: 10}, - {Name: "high", Priority: 1}, - {Name: "mid", Priority: 5}, - } - - fc := NewFallbackChain(providers) - if fc.Providers[0].Name != "high" { - t.Errorf("expected first provider to be high priority, got %s", fc.Providers[0].Name) - } - if fc.Providers[1].Name != "mid" { - t.Errorf("expected second provider to be mid priority, got %s", fc.Providers[1].Name) - } - if fc.Providers[2].Name != "low" { - t.Errorf("expected third provider to be low priority, got %s", fc.Providers[2].Name) - } -} - -func TestSelectProvider(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - p, err := fc.SelectProvider() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.Name != "anthropic" { - t.Errorf("expected anthropic, got %s", p.Name) - } -} - -func TestSelectProviderSkipsCooldown(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - // Put anthropic in cooldown - until := time.Now().Add(10 * time.Minute) - fc.HealthStatus["anthropic"].CooldownUntil = &until - fc.HealthStatus["anthropic"].Status = statusDown - - p, err := fc.SelectProvider() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.Name != "openai" { - t.Errorf("expected openai (next healthy), got %s", p.Name) - } -} - -func TestSelectProviderAllDown(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - until := time.Now().Add(10 * time.Minute) - for name := range fc.HealthStatus { - fc.HealthStatus[name].Status = statusDown - fc.HealthStatus[name].CooldownUntil = &until - } - - _, err := fc.SelectProvider() - if err == nil { - t.Fatal("expected error when all providers are down") - } -} - -func TestRecordSuccess(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - // Simulate some failures first - for i := 0; i < 3; i++ { - fc.RecordFailure("anthropic", fmt.Errorf("timeout")) - } - - // Now record success - fc.RecordSuccess("anthropic", 500*time.Millisecond) - - h := fc.HealthStatus["anthropic"] - if h.ConsecutiveFailures != 0 { - t.Errorf("expected 0 consecutive failures after success, got %d", h.ConsecutiveFailures) - } - if h.Status != statusHealthy { - t.Errorf("expected healthy status after success, got %s", h.Status) - } - if h.Latency != 500*time.Millisecond { - t.Errorf("expected latency 500ms, got %v", h.Latency) - } - if h.CooldownUntil != nil { - t.Error("expected cooldown to be cleared after success") - } - if fc.ActiveProvider != "anthropic" { - t.Errorf("expected active provider to be anthropic, got %s", fc.ActiveProvider) - } -} - -func TestRecordFailureDegradedThreshold(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - for i := 0; i < degradedThreshold; i++ { - fc.RecordFailure("anthropic", fmt.Errorf("error %d", i)) - } - - h := fc.HealthStatus["anthropic"] - if h.Status != statusDegraded { - t.Errorf("expected degraded status after %d failures, got %s", degradedThreshold, h.Status) - } - if h.ConsecutiveFailures != degradedThreshold { - t.Errorf("expected %d failures, got %d", degradedThreshold, h.ConsecutiveFailures) - } -} - -func TestRecordFailureDownThreshold(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - for i := 0; i < downThreshold; i++ { - fc.RecordFailure("anthropic", fmt.Errorf("error %d", i)) - } - - h := fc.HealthStatus["anthropic"] - if h.Status != statusDown { - t.Errorf("expected down status after %d failures, got %s", downThreshold, h.Status) - } - if h.CooldownUntil == nil { - t.Error("expected cooldown to be set when down") - } - if time.Until(*h.CooldownUntil) <= 0 { - t.Error("expected cooldown to be in the future") - } -} - -func TestGetFallback(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - p, err := fc.GetFallback("anthropic") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.Name != "openai" { - t.Errorf("expected openai as fallback for anthropic, got %s", p.Name) - } - - p, err = fc.GetFallback("openai") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.Name != "ollama" { - t.Errorf("expected ollama as fallback for openai, got %s", p.Name) - } -} - -func TestGetFallbackWrapsAround(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - // Last provider should wrap around to first - p, err := fc.GetFallback("ollama") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.Name != "anthropic" { - t.Errorf("expected anthropic as wraparound fallback for ollama, got %s", p.Name) - } -} - -func TestGetFallbackSkipsDown(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - // Mark openai as down with cooldown - until := time.Now().Add(10 * time.Minute) - fc.HealthStatus["openai"].Status = statusDown - fc.HealthStatus["openai"].CooldownUntil = &until - - p, err := fc.GetFallback("anthropic") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.Name != "ollama" { - t.Errorf("expected ollama (skipping down openai), got %s", p.Name) - } -} - -func TestGetFallbackNoAvailable(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - until := time.Now().Add(10 * time.Minute) - fc.HealthStatus["openai"].Status = statusDown - fc.HealthStatus["openai"].CooldownUntil = &until - fc.HealthStatus["ollama"].Status = statusDown - fc.HealthStatus["ollama"].CooldownUntil = &until - - _, err := fc.GetFallback("anthropic") - if err == nil { - t.Fatal("expected error when no fallback available") - } -} - -func TestIsHealthy(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - if !fc.IsHealthy("anthropic") { - t.Error("expected anthropic to be healthy initially") - } - - // Mark as degraded - should not be considered healthy - fc.mu.Lock() - fc.HealthStatus["anthropic"].Status = statusDegraded - fc.mu.Unlock() - - if fc.IsHealthy("anthropic") { - t.Error("expected degraded provider to not be healthy") - } - - // Unknown provider - if fc.IsHealthy("unknown") { - t.Error("expected unknown provider to not be healthy") - } -} - -func TestIsHealthyWithCooldown(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - fc.mu.Lock() - until := time.Now().Add(10 * time.Minute) - fc.HealthStatus["anthropic"].CooldownUntil = &until - fc.mu.Unlock() - - if fc.IsHealthy("anthropic") { - t.Error("expected provider in cooldown to not be healthy") - } -} - -func TestFormatStatus(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - fc.RecordSuccess("anthropic", 1200*time.Millisecond) - fc.RecordSuccess("openai", 3400*time.Millisecond) - - // Make ollama down - for i := 0; i < downThreshold; i++ { - fc.RecordFailure("ollama", fmt.Errorf("connection refused")) - } - - status := fc.FormatStatus() - - if !strings.Contains(status, "Provider Status:") { - t.Error("expected status header") - } - if !strings.Contains(status, "anthropic") { - t.Error("expected anthropic in status") - } - if !strings.Contains(status, "HEALTHY") { - t.Error("expected HEALTHY tag") - } - if !strings.Contains(status, "DOWN") { - t.Error("expected DOWN tag for ollama") - } - if !strings.Contains(status, "Active:") { - t.Error("expected Active section") - } - if !strings.Contains(status, "Fallback chain:") { - t.Error("expected Fallback chain section") - } - if !strings.Contains(status, "→") { - t.Error("expected arrow separator in fallback chain") - } -} - -func TestRecoverProvider(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - // Drive provider to down state - for i := 0; i < downThreshold; i++ { - fc.RecordFailure("anthropic", fmt.Errorf("error")) - } - - if fc.IsHealthy("anthropic") { - t.Fatal("expected provider to be down before recovery") - } - - fc.RecoverProvider("anthropic") - - if !fc.IsHealthy("anthropic") { - t.Error("expected provider to be healthy after recovery") - } - - h := fc.HealthStatus["anthropic"] - if h.ConsecutiveFailures != 0 { - t.Errorf("expected 0 failures after recovery, got %d", h.ConsecutiveFailures) - } - if h.CooldownUntil != nil { - t.Error("expected cooldown cleared after recovery") - } -} - -func TestRecoverUnknownProvider(t *testing.T) { - fc := NewFallbackChain(testProviders()) - // Should not panic - fc.RecoverProvider("nonexistent") -} - -func TestAllDown(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - if fc.AllDown() { - t.Error("expected AllDown to be false initially") - } - - // Take all providers down - until := time.Now().Add(10 * time.Minute) - fc.mu.Lock() - for name := range fc.HealthStatus { - fc.HealthStatus[name].Status = statusDown - fc.HealthStatus[name].CooldownUntil = &until - } - fc.mu.Unlock() - - if !fc.AllDown() { - t.Error("expected AllDown to be true when all providers are down with cooldown") - } -} - -func TestAllDownReturnsFalseIfOneHealthy(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - until := time.Now().Add(10 * time.Minute) - fc.mu.Lock() - fc.HealthStatus["anthropic"].Status = statusDown - fc.HealthStatus["anthropic"].CooldownUntil = &until - fc.HealthStatus["openai"].Status = statusDown - fc.HealthStatus["openai"].CooldownUntil = &until - // ollama stays healthy - fc.mu.Unlock() - - if fc.AllDown() { - t.Error("expected AllDown to be false when ollama is still healthy") - } -} - -func TestBestLatency(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - fc.RecordSuccess("anthropic", 1200*time.Millisecond) - fc.RecordSuccess("openai", 800*time.Millisecond) - fc.RecordSuccess("ollama", 2000*time.Millisecond) - - best := fc.BestLatency() - if best == nil { - t.Fatal("expected a provider from BestLatency") - } - if best.Name != "openai" { - t.Errorf("expected openai (800ms) as best latency, got %s", best.Name) - } -} - -func TestBestLatencySkipsDown(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - fc.RecordSuccess("anthropic", 1200*time.Millisecond) - fc.RecordSuccess("openai", 200*time.Millisecond) - fc.RecordSuccess("ollama", 2000*time.Millisecond) - - // Mark the fastest as down - fc.mu.Lock() - fc.HealthStatus["openai"].Status = statusDown - fc.mu.Unlock() - - best := fc.BestLatency() - if best == nil { - t.Fatal("expected a provider from BestLatency") - } - if best.Name != "anthropic" { - t.Errorf("expected anthropic as best latency (openai is down), got %s", best.Name) - } -} - -func TestBestLatencyNoRecorded(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - best := fc.BestLatency() - if best != nil { - t.Errorf("expected nil when no latency recorded, got %s", best.Name) - } -} - -func TestCooldownExpiry(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - // Set cooldown in the past (already expired) - past := time.Now().Add(-1 * time.Second) - fc.mu.Lock() - fc.HealthStatus["anthropic"].Status = statusDown - fc.HealthStatus["anthropic"].CooldownUntil = &past - fc.mu.Unlock() - - // Provider should be selectable since cooldown expired - p, err := fc.SelectProvider() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.Name != "anthropic" { - t.Errorf("expected anthropic (cooldown expired), got %s", p.Name) - } -} - -func TestConcurrentAccess(t *testing.T) { - fc := NewFallbackChain(testProviders()) - done := make(chan struct{}) - - // Run concurrent operations - go func() { - for i := 0; i < 100; i++ { - fc.RecordSuccess("anthropic", time.Duration(i)*time.Millisecond) - } - done <- struct{}{} - }() - - go func() { - for i := 0; i < 100; i++ { - fc.RecordFailure("openai", fmt.Errorf("err %d", i)) - } - done <- struct{}{} - }() - - go func() { - for i := 0; i < 100; i++ { - _, _ = fc.SelectProvider() - } - done <- struct{}{} - }() - - go func() { - for i := 0; i < 100; i++ { - fc.IsHealthy("anthropic") - fc.IsHealthy("openai") - } - done <- struct{}{} - }() - - go func() { - for i := 0; i < 100; i++ { - fc.AllDown() - fc.BestLatency() - fc.FormatStatus() - } - done <- struct{}{} - }() - - for i := 0; i < 5; i++ { - <-done - } -} - -func TestFallbackChainIntegration(t *testing.T) { - fc := NewFallbackChain(testProviders()) - - // Simulate: primary goes down, system falls back - // 1. Start with anthropic - p, _ := fc.SelectProvider() - if p.Name != "anthropic" { - t.Fatalf("expected initial provider anthropic, got %s", p.Name) - } - - // 2. Anthropic starts failing - for i := 0; i < downThreshold; i++ { - fc.RecordFailure("anthropic", fmt.Errorf("rate limited")) - } - - // 3. System should fall back to openai - p, _ = fc.SelectProvider() - if p.Name != "openai" { - t.Errorf("expected fallback to openai, got %s", p.Name) - } - - // 4. OpenAI also degrades - for i := 0; i < downThreshold; i++ { - fc.RecordFailure("openai", fmt.Errorf("server error")) - } - - // 5. System should use ollama - p, _ = fc.SelectProvider() - if p.Name != "ollama" { - t.Errorf("expected fallback to ollama, got %s", p.Name) - } - - // 6. Recover anthropic manually - fc.RecoverProvider("anthropic") - p, _ = fc.SelectProvider() - if p.Name != "anthropic" { - t.Errorf("expected recovered anthropic, got %s", p.Name) - } -} - -func TestFormatDuration(t *testing.T) { - tests := []struct { - d time.Duration - want string - }{ - {0, "n/a"}, - {500 * time.Millisecond, "500ms"}, - {1200 * time.Millisecond, "1.2s"}, - {3 * time.Second, "3.0s"}, - } - - for _, tt := range tests { - got := formatDuration(tt.d) - if got != tt.want { - t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) - } - } -} diff --git a/internal/provider/routing/roles.go b/internal/provider/routing/roles.go index 03cfc5eb..7ea8b461 100644 --- a/internal/provider/routing/roles.go +++ b/internal/provider/routing/roles.go @@ -1,12 +1,13 @@ package routing import ( + "context" "strings" - eycatalog "github.com/GrayCodeAI/eyrie/catalog" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) -// Role identifies the purpose of a model within a multi-model workflow. +// Role identifies the purpose of a model within a Hawk multi-model workflow. type Role string const ( @@ -16,8 +17,6 @@ const ( RoleCommit Role = "commit" ) -// ModelRoles maps each role to a specific model name. -// Empty fields fall back to the primary (coder) model. type ModelRoles struct { Planner string `json:"planner,omitempty"` Coder string `json:"coder,omitempty"` @@ -25,38 +24,46 @@ type ModelRoles struct { Commit string `json:"commit,omitempty"` } -// DefaultRoles returns a ModelRoles where every role uses primaryModel except -// Commit, which defaults to the cheapest available model from the catalog. +// DefaultRoles keeps Hawk's workflow policy while asking Eyrie for the +// economical same-provider model used for commit/summarization work. func DefaultRoles(primaryModel string) ModelRoles { - return fromEyrieRoles(eycatalog.DefaultModelRolesV1(eyrieCatalog(), primaryModel)) + primaryModel = strings.TrimSpace(primaryModel) + commit := primaryModel + if engine := eyrieModelEngine(); engine != nil { + if provider := engine.ProviderForModel(context.Background(), primaryModel); provider != "" { + commit = engine.PreferredModel(context.Background(), provider, eyrieengine.ModelClassEconomical, primaryModel) + } + } + return ModelRoles{Planner: primaryModel, Coder: primaryModel, Reviewer: primaryModel, Commit: commit} } -// ModelForRole returns the model name assigned to role, falling back to the -// Coder model (primary) if the role-specific field is empty. func (r ModelRoles) ModelForRole(role Role) string { - return eycatalog.ModelForRoleV1(eyrieCatalog(), toEyrieRoles(r), eycatalog.ModelRole(role)) -} - -// CheapestForProvider queries eyrie's catalog at runtime and returns the -// cheapest model for the given provider. No hardcoded model names. -func CheapestForProvider(provider, fallback string) string { - return eycatalog.CheapestModelForProvider(eyrieCatalog(), provider, fallback) -} - -func toEyrieRoles(r ModelRoles) eycatalog.ModelRoleAssignments { - return eycatalog.ModelRoleAssignments{ - Planner: strings.TrimSpace(r.Planner), - Coder: strings.TrimSpace(r.Coder), - Reviewer: strings.TrimSpace(r.Reviewer), - Commit: strings.TrimSpace(r.Commit), + var model string + switch role { + case RolePlanner: + model = r.Planner + case RoleCoder: + model = r.Coder + case RoleReviewer: + model = r.Reviewer + case RoleCommit: + model = r.Commit + } + if model = strings.TrimSpace(model); model != "" { + return model + } + if coder := strings.TrimSpace(r.Coder); coder != "" { + return coder } + if engine := eyrieModelEngine(); engine != nil { + return engine.PrimaryModel(context.Background()) + } + return "" } -func fromEyrieRoles(r eycatalog.ModelRoleAssignments) ModelRoles { - return ModelRoles{ - Planner: strings.TrimSpace(r.Planner), - Coder: strings.TrimSpace(r.Coder), - Reviewer: strings.TrimSpace(r.Reviewer), - Commit: strings.TrimSpace(r.Commit), +func CheapestForProvider(provider, fallback string) string { + if engine := eyrieModelEngine(); engine != nil { + return engine.PreferredModel(context.Background(), provider, eyrieengine.ModelClassEconomical, fallback) } + return fallback } diff --git a/internal/provider/routing/router.go b/internal/provider/routing/router.go deleted file mode 100644 index b970def1..00000000 --- a/internal/provider/routing/router.go +++ /dev/null @@ -1,250 +0,0 @@ -package routing - -import ( - "fmt" - "sync" - "time" -) - -// RoutingStrategy determines how the router selects providers. -type RoutingStrategy string - -const ( - StrategyLatency RoutingStrategy = "latency" - StrategyCost RoutingStrategy = "cost" - StrategyBalanced RoutingStrategy = "balanced" -) - -// LatencyClass categorizes model response speed. -type LatencyClass string - -const ( - LatencyFast LatencyClass = "fast" - LatencyMedium LatencyClass = "medium" - LatencySlow LatencyClass = "slow" -) - -// Capabilities describes what a model supports. -type Capabilities struct { - Streaming bool `json:"streaming"` - FunctionCalling bool `json:"function_calling"` - Vision bool `json:"vision"` - JSON bool `json:"json"` - Thinking bool `json:"thinking"` -} - -// ProviderHealth tracks the health state of a provider. -type ProviderHealth struct { - Available bool `json:"available"` - LastCheck time.Time `json:"last_check"` - LastSuccess time.Time `json:"last_success"` - ConsecutiveFails int `json:"consecutive_fails"` - AvgLatencyMs float64 `json:"avg_latency_ms"` -} - -// CircuitState represents circuit breaker state. -type CircuitState int - -const ( - CircuitClosed CircuitState = iota // normal - CircuitOpen // rejecting - CircuitHalfOpen // testing -) - -const ( - circuitOpenThreshold = 3 - circuitRecoveryDelay = 30 * time.Second - circuitHalfOpenPasses = 3 - latencyEMAAlpha = 0.3 -) - -// Router provides health-aware provider routing. -type Router struct { - mu sync.RWMutex - health map[string]*ProviderHealth - circuits map[string]*circuitBreaker - strategy RoutingStrategy -} - -type circuitBreaker struct { - state CircuitState - failures int - lastFailure time.Time - halfOpenPass int -} - -// NewRouter creates a new provider router. -func NewRouter(strategy RoutingStrategy) *Router { - return &Router{ - health: make(map[string]*ProviderHealth), - circuits: make(map[string]*circuitBreaker), - strategy: strategy, - } -} - -// SelectProvider returns the preferred provider if it's available, or an error -// if the provider's circuit breaker is open. No cross-provider fallback. -func (r *Router) SelectProvider(preferred string) (string, error) { - r.mu.Lock() - defer r.mu.Unlock() - - if preferred == "" { - return "", fmt.Errorf("no provider specified") - } - - if r.isAvailable(preferred) { - return preferred, nil - } - - return "", fmt.Errorf("provider %q is unavailable (circuit open)", preferred) -} - -// SelectProviderForModel chooses the best provider for a specific model. -func (r *Router) SelectProviderForModel(modelName string) (string, ModelInfo, error) { - info, found := Find(modelName) - if !found { - return "", ModelInfo{}, fmt.Errorf("model %q not found in catalog", modelName) - } - - provider, err := r.SelectProvider(info.Provider) - if err != nil { - return "", ModelInfo{}, err - } - return provider, info, nil -} - -// RecordSuccess records a successful API call for a provider. -func (r *Router) RecordSuccess(provider string, latency time.Duration) { - r.mu.Lock() - defer r.mu.Unlock() - - h := r.getOrCreateHealth(provider) - h.Available = true - h.LastSuccess = time.Now() - h.LastCheck = time.Now() - h.ConsecutiveFails = 0 - - latencyMs := float64(latency.Milliseconds()) - if h.AvgLatencyMs == 0 { - h.AvgLatencyMs = latencyMs - } else { - h.AvgLatencyMs = latencyEMAAlpha*latencyMs + (1-latencyEMAAlpha)*h.AvgLatencyMs - } - - cb := r.getOrCreateCircuit(provider) - if cb.state == CircuitHalfOpen { - cb.halfOpenPass++ - if cb.halfOpenPass >= circuitHalfOpenPasses { - cb.state = CircuitClosed - cb.failures = 0 - cb.halfOpenPass = 0 - } - } else if cb.state == CircuitOpen { - cb.state = CircuitClosed - cb.failures = 0 - } -} - -// RecordFailure records a failed API call for a provider. -func (r *Router) RecordFailure(provider string, err error) { - r.mu.Lock() - defer r.mu.Unlock() - - h := r.getOrCreateHealth(provider) - h.ConsecutiveFails++ - h.LastCheck = time.Now() - - cb := r.getOrCreateCircuit(provider) - cb.failures++ - cb.lastFailure = time.Now() - - if cb.failures >= circuitOpenThreshold { - cb.state = CircuitOpen - h.Available = false - } -} - -// HealthStatus returns health info for all tracked providers. -func (r *Router) HealthStatus() map[string]*ProviderHealth { - r.mu.RLock() - defer r.mu.RUnlock() - out := make(map[string]*ProviderHealth, len(r.health)) - for k, v := range r.health { - copy := *v - out[k] = © - } - return out -} - -// Score returns a routing score for a provider (lower is better). -func (r *Router) Score(provider string) float64 { - r.mu.RLock() - defer r.mu.RUnlock() - - h, ok := r.health[provider] - if !ok { - return 100.0 // unknown provider, neutral score - } - - var score float64 - switch r.strategy { - case StrategyLatency: - score = h.AvgLatencyMs / 1000.0 - case StrategyCost: - if m, ok := Recommended(provider); ok { - score = m.InputPrice - } - case StrategyBalanced: - latencyScore := h.AvgLatencyMs / 1000.0 - costScore := 0.0 - if m, ok := Recommended(provider); ok { - costScore = m.InputPrice - } - score = latencyScore*0.5 + costScore*0.5 - } - - // Penalty for recent failures - score += float64(h.ConsecutiveFails) * 10.0 - - return score -} - -func (r *Router) isAvailable(provider string) bool { - cb, ok := r.circuits[provider] - if !ok { - return true - } - - switch cb.state { - case CircuitClosed: - return true - case CircuitOpen: - if time.Since(cb.lastFailure) > circuitRecoveryDelay { - cb.state = CircuitHalfOpen - cb.halfOpenPass = 0 - return true - } - return false - case CircuitHalfOpen: - return true - } - return true -} - -func (r *Router) getOrCreateHealth(provider string) *ProviderHealth { - h, ok := r.health[provider] - if !ok { - h = &ProviderHealth{Available: true} - r.health[provider] = h - } - return h -} - -func (r *Router) getOrCreateCircuit(provider string) *circuitBreaker { - cb, ok := r.circuits[provider] - if !ok { - cb = &circuitBreaker{state: CircuitClosed} - r.circuits[provider] = cb - } - return cb -} diff --git a/internal/provider/routing/router_test.go b/internal/provider/routing/router_test.go deleted file mode 100644 index cbc49757..00000000 --- a/internal/provider/routing/router_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package routing - -import ( - "testing" - "time" -) - -func TestNewRouter(t *testing.T) { - r := NewRouter(StrategyBalanced) - if r == nil { - t.Fatal("expected non-nil router") - } -} - -func TestRouter_SelectProvider_Preferred(t *testing.T) { - r := NewRouter(StrategyLatency) - - provider, err := r.SelectProvider("anthropic") - if err != nil { - t.Fatalf("SelectProvider error: %v", err) - } - if provider != "anthropic" { - t.Errorf("expected anthropic, got %s", provider) - } -} - -func TestRouter_SelectProvider_Unavailable(t *testing.T) { - r := NewRouter(StrategyLatency) - - // Mark preferred as down - for i := 0; i < 3; i++ { - r.RecordFailure("anthropic", nil) - } - - _, err := r.SelectProvider("anthropic") - if err == nil { - t.Error("expected error when provider circuit is open") - } -} - -func TestRouter_SelectProvider_Empty(t *testing.T) { - r := NewRouter(StrategyLatency) - - _, err := r.SelectProvider("") - if err == nil { - t.Error("expected error when no provider specified") - } -} - -func TestRouter_CircuitBreaker(t *testing.T) { - r := NewRouter(StrategyLatency) - - // Record failures to open circuit - for i := 0; i < 3; i++ { - r.RecordFailure("anthropic", nil) - } - - health := r.HealthStatus() - if h, ok := health["anthropic"]; ok { - if h.Available { - t.Error("provider should be unavailable after circuit opens") - } - if h.ConsecutiveFails != 3 { - t.Errorf("expected 3 consecutive fails, got %d", h.ConsecutiveFails) - } - } else { - t.Error("expected health entry for anthropic") - } -} - -func TestRouter_CircuitBreaker_Recovery(t *testing.T) { - r := NewRouter(StrategyLatency) - - // Open circuit - for i := 0; i < 3; i++ { - r.RecordFailure("anthropic", nil) - } - - // Record success should close circuit - r.RecordSuccess("anthropic", 100*time.Millisecond) - - health := r.HealthStatus() - if h := health["anthropic"]; h != nil { - if !h.Available { - t.Error("provider should be available after recovery") - } - if h.ConsecutiveFails != 0 { - t.Errorf("expected 0 fails after recovery, got %d", h.ConsecutiveFails) - } - } -} - -func TestRouter_RecordSuccess_Latency(t *testing.T) { - r := NewRouter(StrategyLatency) - - r.RecordSuccess("anthropic", 200*time.Millisecond) - r.RecordSuccess("anthropic", 100*time.Millisecond) - - health := r.HealthStatus() - h := health["anthropic"] - if h == nil { - t.Fatal("expected health entry") - } - if h.AvgLatencyMs == 0 { - t.Error("expected non-zero avg latency") - } - // EMA should be between 100 and 200 - if h.AvgLatencyMs < 100 || h.AvgLatencyMs > 200 { - t.Errorf("EMA should be between 100 and 200, got %f", h.AvgLatencyMs) - } -} - -func TestRouter_Score(t *testing.T) { - r := NewRouter(StrategyLatency) - - r.RecordSuccess("anthropic", 100*time.Millisecond) - r.RecordSuccess("openai", 500*time.Millisecond) - - scoreA := r.Score("anthropic") - scoreO := r.Score("openai") - if scoreA >= scoreO { - t.Errorf("anthropic (faster) should have lower score: %f vs %f", scoreA, scoreO) - } -} - -func TestRouter_Score_WithFailures(t *testing.T) { - r := NewRouter(StrategyLatency) - - r.RecordSuccess("anthropic", 100*time.Millisecond) - r.RecordFailure("anthropic", nil) - r.RecordFailure("anthropic", nil) - - scoreA := r.Score("anthropic") - if scoreA <= 10 { - t.Errorf("score should include failure penalty, got %f", scoreA) - } -} - -func TestRouter_SelectProviderForModel(t *testing.T) { - r := NewRouter(StrategyBalanced) - - provider, info, err := r.SelectProviderForModel("gpt-4o") - if err != nil { - t.Fatalf("SelectProviderForModel error: %v", err) - } - if provider != "openai" { - t.Errorf("expected openai, got %s", provider) - } - if info.Name != "openai/gpt-4o" { - t.Errorf("expected canonical openai/gpt-4o, got %s", info.Name) - } -} - -func TestRouter_SelectProviderForModel_ProviderDown(t *testing.T) { - r := NewRouter(StrategyBalanced) - - // Mark openai as down - for i := 0; i < 3; i++ { - r.RecordFailure("openai", nil) - } - - _, _, err := r.SelectProviderForModel("gpt-4o") - if err == nil { - t.Error("expected error when model's provider is down") - } -} - -func TestRouter_SelectProviderForModel_Unknown(t *testing.T) { - r := NewRouter(StrategyBalanced) - - _, _, err := r.SelectProviderForModel("nonexistent-model") - if err == nil { - t.Error("expected error for unknown model") - } -} diff --git a/internal/provider/routing/tiers.go b/internal/provider/routing/tiers.go index a9185f49..1b9236b3 100644 --- a/internal/provider/routing/tiers.go +++ b/internal/provider/routing/tiers.go @@ -1,128 +1,93 @@ package routing import ( - "sort" - "strings" + "context" - eycatalog "github.com/GrayCodeAI/eyrie/catalog" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" ) -// CostTier is a relative cost band for cascade routing (cheap / mid / expensive). -type CostTier = eycatalog.ModelCostTier +type CostTier int const ( - CostTierCheap = eycatalog.CostTierCheap - CostTierMid = eycatalog.CostTierMid - CostTierExpensive = eycatalog.CostTierExpensive + CostTierCheap CostTier = iota + CostTierMid + CostTierExpensive +) + +type PreferenceTier = eyrieengine.ModelClass + +const ( + TierHaiku PreferenceTier = eyrieengine.ModelClassEconomical + TierSonnet PreferenceTier = eyrieengine.ModelClassBalanced + TierOpus PreferenceTier = eyrieengine.ModelClassPremium ) -// CostTierOf resolves a model's cost tier from eyrie catalog policy. func CostTierOf(modelName string) CostTier { - return eycatalog.ModelCostTierOf(eyrieCatalog(), modelName) + if engine := eyrieModelEngine(); engine != nil { + switch engine.ModelClassOf(context.Background(), modelName) { + case eyrieengine.ModelClassEconomical: + return CostTierCheap + case eyrieengine.ModelClassPremium: + return CostTierExpensive + } + } + return CostTierMid } -// TierModels returns eyrie-preferred model IDs for haiku, sonnet, and opus tiers. func TierModels(provider string) (haiku, sonnet, opus string) { - return PreferredModelForTier(provider, eycatalog.TierHaiku, ""), - PreferredModelForTier(provider, eycatalog.TierSonnet, ""), - PreferredModelForTier(provider, eycatalog.TierOpus, "") + return PreferredModelForTier(provider, TierHaiku, ""), + PreferredModelForTier(provider, TierSonnet, ""), + PreferredModelForTier(provider, TierOpus, "") } -// RolesForProvider builds standard planner/coder/reviewer/commit roles from the catalog. func RolesForProvider(provider string) ModelRoles { haiku, sonnet, opus := TierModels(provider) - return ModelRoles{ - Planner: opus, - Coder: sonnet, - Reviewer: sonnet, - Commit: haiku, - } + return ModelRoles{Planner: opus, Coder: sonnet, Reviewer: sonnet, Commit: haiku} } -// SuggestTierForTask maps a task type to an eyrie cost tier (not a concrete model ID). -func SuggestTierForTask(taskType string) eycatalog.ModelTier { +func SuggestTierForTask(taskType string) PreferenceTier { switch taskType { case "simple": - return eycatalog.TierHaiku + return TierHaiku case "generation": - return eycatalog.TierOpus + return TierOpus default: - return eycatalog.TierSonnet + return TierSonnet } } -// AllCatalogModelNames returns model IDs from the eyrie catalog cache. func AllCatalogModelNames() []string { - return catalogModelNames(eyrieCatalog()) -} - -func catalogModelNames(compiled *eycatalog.CompiledCatalog) []string { - if compiled == nil { - return nil + if engine := eyrieModelEngine(); engine != nil { + return engine.ModelNames(context.Background()) } - seen := map[string]bool{} - var out []string - for id, model := range compiled.ModelsByID { - if id != "" && !seen[id] { - seen[id] = true - out = append(out, id) - } - if model.Name != "" && !seen[model.Name] { - seen[model.Name] = true - out = append(out, model.Name) - } - } - if compiled.Catalog == nil { - sort.Strings(out) - return out - } - for alias, canonical := range compiled.Catalog.Aliases { - if alias != "" && !seen[alias] { - seen[alias] = true - out = append(out, alias) - } - if canonical != "" && !seen[canonical] { - seen[canonical] = true - out = append(out, canonical) - } - } - sort.Strings(out) - return out + return nil } -// DefaultHealthTiers builds complexity-based routing tiers from the eyrie catalog. func DefaultHealthTiers(primaryProvider string) []ModelTier { primaryProvider = canonicalProvider(primaryProvider) - light := tierModelList(primaryProvider, eycatalog.TierHaiku) - standard := tierModelList(primaryProvider, eycatalog.TierSonnet) - heavy := tierModelList(primaryProvider, eycatalog.TierOpus) return []ModelTier{ - {Name: "light", Models: light, MaxComplexity: 10.0}, - {Name: "standard", Models: standard, MaxComplexity: 30.0}, - {Name: "heavy", Models: heavy, MaxComplexity: 1e9}, + {Name: "light", Models: tierModelList(primaryProvider, TierHaiku), MaxComplexity: 10.0}, + {Name: "standard", Models: tierModelList(primaryProvider, TierSonnet), MaxComplexity: 30.0}, + {Name: "heavy", Models: tierModelList(primaryProvider, TierOpus), MaxComplexity: 1e9}, } } -func tierModelList(primaryProvider string, tier eycatalog.ModelTier) []string { - seen := map[string]bool{} - var out []string - for _, model := range eycatalog.PreferredModelsForTier(eyrieCatalog(), primaryProvider, tier, 3) { - model = strings.TrimSpace(model) - if model == "" || seen[model] { - continue - } - seen[model] = true - out = append(out, model) +func tierModelList(primaryProvider string, tier PreferenceTier) []string { + engine := eyrieModelEngine() + if engine == nil { + return nil } - return out + models := engine.PreferredModels(context.Background(), primaryProvider, tier, 3) + return models } -// PreferredModelForTier returns the eyrie-preferred model for a provider and tier. -func PreferredModelForTier(provider string, tier eycatalog.ModelTier, fallback string) string { - return eycatalog.PreferredProviderModel(eyrieCatalog(), provider, tier, fallback) +func PreferredModelForTier(provider string, tier PreferenceTier, fallback string) string { + if engine := eyrieModelEngine(); engine != nil { + return engine.PreferredModel(context.Background(), provider, tier, fallback) + } + return fallback } -// MostExpensiveForProvider picks the highest input-priced model for a provider. func MostExpensiveForProvider(provider, fallback string) string { - return eycatalog.MostExpensiveModelForProvider(eyrieCatalog(), provider, fallback) + return PreferredModelForTier(provider, TierOpus, fallback) } diff --git a/internal/provider/routing/tiers_test.go b/internal/provider/routing/tiers_test.go index dd5d47eb..1889e6e4 100644 --- a/internal/provider/routing/tiers_test.go +++ b/internal/provider/routing/tiers_test.go @@ -1,11 +1,6 @@ package routing -import ( - "sync" - "testing" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" -) +import "testing" func TestCostTierOf_CatalogModels(t *testing.T) { tests := []struct { @@ -29,14 +24,14 @@ func TestCostTierOf_CatalogModels(t *testing.T) { } func TestPreferredModelForTier_NilCatalog(t *testing.T) { - got := PreferredModelForTier("unknown-provider-xyz", eycatalog.TierHaiku, "") + got := PreferredModelForTier("unknown-provider-xyz", TierHaiku, "") if got != "" { t.Fatalf("expected empty haiku model for unknown provider, got %q", got) } } func TestPreferredModelForTier_WithFallback(t *testing.T) { - got := PreferredModelForTier("unknown-provider-xyz", eycatalog.TierHaiku, "fallback-model") + got := PreferredModelForTier("unknown-provider-xyz", TierHaiku, "fallback-model") if got != "fallback-model" { t.Fatalf("expected fallback model, got %q", got) } @@ -49,15 +44,7 @@ func TestRolesForProvider_NilCatalog(t *testing.T) { } } -func TestCostTierOf_FallsBackWithoutLoadedCatalog(t *testing.T) { - catalogOnce = sync.Once{} - catalogOnce.Do(func() {}) - cachedCatalog = nil - t.Cleanup(func() { - catalogOnce = sync.Once{} - cachedCatalog = nil - }) - +func TestCostTierOf_FallsBackWithoutCatalogMatch(t *testing.T) { if got := CostTierOf("gpt-4o-mini"); got != CostTierCheap { t.Fatalf("CostTierOf fallback = %v, want %v", got, CostTierCheap) } diff --git a/internal/resilience/health/diagnostics.go b/internal/resilience/health/diagnostics.go index 925e4651..cfbf5700 100644 --- a/internal/resilience/health/diagnostics.go +++ b/internal/resilience/health/diagnostics.go @@ -12,7 +12,6 @@ import ( "sync" "time" - "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/storage" @@ -339,12 +338,17 @@ func checkConfigFileValid() DiagnosticResult { func checkAPIKeySet() DiagnosticResult { start := time.Now() - stored := credentials.StoredEnvKeys(context.Background()) - if len(stored) == 0 { + configured := make([]string, 0) + for _, gateway := range config.GatewayStatuses(context.Background(), "", "") { + if gateway.HasStoredCredential { + configured = append(configured, gateway.ID) + } + } + if len(configured) == 0 { return DiagnosticResult{ Name: "api_key_set", Status: "fail", - Message: "No API keys stored in the OS secret store", + Message: "No API credentials stored in the OS secret store", Fix: "Run /config to save a key, or hawk credentials status to verify storage", Duration: time.Since(start), } @@ -353,7 +357,7 @@ func checkAPIKeySet() DiagnosticResult { return DiagnosticResult{ Name: "api_key_set", Status: "pass", - Message: fmt.Sprintf("API keys stored: %s", strings.Join(stored, ", ")), + Message: fmt.Sprintf("API credentials stored for gateways: %s", strings.Join(configured, ", ")), Duration: time.Since(start), } } diff --git a/internal/resilience/health/diagnostics_test.go b/internal/resilience/health/diagnostics_test.go index b5bcedcd..ac603f18 100644 --- a/internal/resilience/health/diagnostics_test.go +++ b/internal/resilience/health/diagnostics_test.go @@ -357,8 +357,8 @@ func TestCheckAPIKeySet(t *testing.T) { if result.Status != "pass" { t.Errorf("Expected pass when key is in store, got %q: %s", result.Status, result.Message) } - if !strings.Contains(result.Message, "ANTHROPIC_API_KEY") { - t.Errorf("Expected message to mention ANTHROPIC_API_KEY, got %q", result.Message) + if !strings.Contains(result.Message, "anthropic") { + t.Errorf("Expected message to mention the configured gateway, got %q", result.Message) } } diff --git a/internal/session/conversation_graph.go b/internal/session/conversation_graph.go new file mode 100644 index 00000000..dd37affc --- /dev/null +++ b/internal/session/conversation_graph.go @@ -0,0 +1,237 @@ +package session + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +// ConversationNode is one message in Hawk's product-owned conversation graph. +type ConversationNode struct { + ID string `json:"id"` + ParentID string `json:"parent_id,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type conversationGraphState struct { + Version int `json:"version"` + SessionID string `json:"session_id"` + HeadID string `json:"head_id,omitempty"` + Nodes map[string]*ConversationNode `json:"nodes"` +} + +// ConversationGraph persists branching product history independently of the +// LLM runtime. It is safe for concurrent use. +type ConversationGraph struct { + mu sync.RWMutex + path string + state conversationGraphState +} + +// OpenConversationGraph opens or creates a graph at path. +func OpenConversationGraph(path, sessionID string) (*ConversationGraph, error) { + if path == "" || sessionID == "" { + return nil, fmt.Errorf("conversation graph: path and session id are required") + } + g := &ConversationGraph{path: path, state: conversationGraphState{Version: 1, SessionID: sessionID, Nodes: make(map[string]*ConversationNode)}} + data, err := os.ReadFile(path) // #nosec G304 -- path is supplied by Hawk's session storage composition root + if err == nil { + if decodeErr := json.Unmarshal(data, &g.state); decodeErr != nil { + return nil, fmt.Errorf("conversation graph: decode: %w", decodeErr) + } + if g.state.SessionID != sessionID { + return nil, fmt.Errorf("conversation graph: session mismatch") + } + if g.state.Nodes == nil { + g.state.Nodes = make(map[string]*ConversationNode) + } + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("conversation graph: read: %w", err) + } + return g, nil +} + +// Empty reports whether the graph has no nodes. +func (g *ConversationGraph) Empty() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return len(g.state.Nodes) == 0 +} + +// Append adds a child node and advances the graph head. +func (g *ConversationGraph) Append(parentID, role, content string) (*ConversationNode, error) { + g.mu.Lock() + defer g.mu.Unlock() + if parentID != "" { + if _, ok := g.state.Nodes[parentID]; !ok { + return nil, fmt.Errorf("conversation graph: parent %q not found", parentID) + } + } + node := &ConversationNode{ID: conversationNodeID(), ParentID: parentID, Role: role, Content: content, CreatedAt: time.Now().UTC()} + g.state.Nodes[node.ID] = node + g.state.HeadID = node.ID + if err := g.persistLocked(); err != nil { + delete(g.state.Nodes, node.ID) + return nil, err + } + return cloneConversationNode(node), nil +} + +// Fork creates an alternative node at the same parent as nodeID and makes it +// the active head. +func (g *ConversationGraph) Fork(nodeID string) (*ConversationNode, error) { + g.mu.Lock() + defer g.mu.Unlock() + source, ok := g.state.Nodes[nodeID] + if !ok { + return nil, fmt.Errorf("conversation graph: node %q not found", nodeID) + } + fork := cloneConversationNode(source) + fork.ID = conversationNodeID() + fork.CreatedAt = time.Now().UTC() + if fork.Metadata == nil { + fork.Metadata = make(map[string]string) + } + fork.Metadata["forked_from"] = nodeID + g.state.Nodes[fork.ID] = fork + g.state.HeadID = fork.ID + if err := g.persistLocked(); err != nil { + delete(g.state.Nodes, fork.ID) + return nil, err + } + return cloneConversationNode(fork), nil +} + +// History returns the root-to-node path. +func (g *ConversationGraph) History(nodeID string) ([]*ConversationNode, error) { + g.mu.RLock() + defer g.mu.RUnlock() + var reversed []*ConversationNode + seen := make(map[string]bool) + for nodeID != "" { + if seen[nodeID] { + return nil, fmt.Errorf("conversation graph: cycle at %q", nodeID) + } + seen[nodeID] = true + node, ok := g.state.Nodes[nodeID] + if !ok { + return nil, fmt.Errorf("conversation graph: node %q not found", nodeID) + } + reversed = append(reversed, cloneConversationNode(node)) + nodeID = node.ParentID + } + out := make([]*ConversationNode, len(reversed)) + for i := range reversed { + out[len(reversed)-1-i] = reversed[i] + } + return out, nil +} + +// Branches lists direct children of nodeID. An empty nodeID lists roots. +func (g *ConversationGraph) Branches(nodeID string) ([]*ConversationNode, error) { + g.mu.RLock() + defer g.mu.RUnlock() + if nodeID != "" { + if _, ok := g.state.Nodes[nodeID]; !ok { + return nil, fmt.Errorf("conversation graph: node %q not found", nodeID) + } + } + var out []*ConversationNode + for _, node := range g.state.Nodes { + if node.ParentID == nodeID { + out = append(out, cloneConversationNode(node)) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].ID < out[j].ID + } + return out[i].CreatedAt.Before(out[j].CreatedAt) + }) + return out, nil +} + +// Head returns the active branch head. +func (g *ConversationGraph) Head() (*ConversationNode, error) { + g.mu.RLock() + defer g.mu.RUnlock() + if g.state.HeadID == "" { + return nil, fmt.Errorf("conversation graph: no head") + } + node, ok := g.state.Nodes[g.state.HeadID] + if !ok { + return nil, fmt.Errorf("conversation graph: head not found") + } + return cloneConversationNode(node), nil +} + +// SetHead switches the active branch. +func (g *ConversationGraph) SetHead(nodeID string) error { + g.mu.Lock() + defer g.mu.Unlock() + if _, ok := g.state.Nodes[nodeID]; !ok { + return fmt.Errorf("conversation graph: node %q not found", nodeID) + } + previous := g.state.HeadID + g.state.HeadID = nodeID + if err := g.persistLocked(); err != nil { + g.state.HeadID = previous + return err + } + return nil +} + +// Close flushes graph state. +func (g *ConversationGraph) Close() error { + g.mu.Lock() + defer g.mu.Unlock() + return g.persistLocked() +} + +func (g *ConversationGraph) persistLocked() error { + if err := os.MkdirAll(filepath.Dir(g.path), 0o750); err != nil { + return fmt.Errorf("conversation graph: create directory: %w", err) + } + data, err := json.MarshalIndent(g.state, "", " ") + if err != nil { + return fmt.Errorf("conversation graph: encode: %w", err) + } + tmp := g.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("conversation graph: write: %w", err) + } + if err := os.Rename(tmp, g.path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("conversation graph: replace: %w", err) + } + return nil +} + +func cloneConversationNode(node *ConversationNode) *ConversationNode { + if node == nil { + return nil + } + copy := *node + if node.Metadata != nil { + copy.Metadata = make(map[string]string, len(node.Metadata)) + for key, value := range node.Metadata { + copy.Metadata[key] = value + } + } + return © +} + +func conversationNodeID() string { + bytes := make([]byte, 16) + _, _ = rand.Read(bytes) + return fmt.Sprintf("%x", bytes) +} diff --git a/internal/session/conversation_graph_test.go b/internal/session/conversation_graph_test.go new file mode 100644 index 00000000..f7aa9dfe --- /dev/null +++ b/internal/session/conversation_graph_test.go @@ -0,0 +1,58 @@ +package session + +import ( + "path/filepath" + "testing" +) + +func TestConversationGraphPersistsBranches(t *testing.T) { + path := filepath.Join(t.TempDir(), "graph.json") + graph, err := OpenConversationGraph(path, "session-1") + if err != nil { + t.Fatal(err) + } + root, err := graph.Append("", "user", "hello") + if err != nil { + t.Fatal(err) + } + answer, err := graph.Append(root.ID, "assistant", "answer") + if err != nil { + t.Fatal(err) + } + fork, err := graph.Fork(answer.ID) + if err != nil { + t.Fatal(err) + } + if fork.Metadata["forked_from"] != answer.ID { + t.Fatalf("fork metadata = %+v", fork.Metadata) + } + if closeErr := graph.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + reopened, err := OpenConversationGraph(path, "session-1") + if err != nil { + t.Fatal(err) + } + head, err := reopened.Head() + if err != nil { + t.Fatal(err) + } + if head.ID != fork.ID { + t.Fatalf("head = %q, want %q", head.ID, fork.ID) + } + history, err := reopened.History(fork.ID) + if err != nil { + t.Fatal(err) + } + if len(history) != 2 || history[0].ID != root.ID || history[1].ID != fork.ID { + t.Fatalf("history = %+v", history) + } + children, err := reopened.Branches(root.ID) + if err != nil { + t.Fatal(err) + } + if len(children) != 2 { + t.Fatalf("root children = %d, want 2", len(children)) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index bb58a0a5..b310c1a2 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3,6 +3,7 @@ package session import ( "bufio" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -37,6 +38,7 @@ type Session struct { ID string `json:"id"` Model string `json:"model"` Provider string `json:"provider"` + Agent string `json:"agent,omitempty"` CWD string `json:"cwd,omitempty"` Name string `json:"name,omitempty"` Messages []Message `json:"messages"` @@ -44,6 +46,10 @@ type Session struct { UpdatedAt time.Time `json:"updated_at"` } +// ErrNotFound identifies a missing durable session without conflating it with +// corruption or I/O failures in one of the supported on-disk formats. +var ErrNotFound = errors.New("session not found") + func sessionsDir() string { return storage.SessionsDir() } @@ -96,6 +102,7 @@ func Save(s *Session) error { "id": s.ID, "model": s.Model, "provider": s.Provider, + "agent": s.Agent, "cwd": s.CWD, "name": s.Name, "created_at": s.CreatedAt.Format(time.RFC3339), @@ -274,6 +281,9 @@ func RecoverFromWAL(sessionID string) (*Session, error) { if v, ok := raw["provider"].(string); ok { s.Provider = v } + if v, ok := raw["agent"].(string); ok { + s.Agent = v + } if v, ok := raw["cwd"].(string); ok { s.CWD = v } @@ -321,13 +331,18 @@ func CheckForRecovery() []string { // Load reads a session from disk, supporting both JSONL and legacy JSON formats. func Load(id string) (*Session, error) { // Try JSONL first - if s, err := loadJSONL(id); err == nil { + s, jsonlErr := loadJSONL(id) + if jsonlErr == nil { return s, nil } - if s, err := loadLegacyJSON(id); err == nil { + s, legacyErr := loadLegacyJSON(id) + if legacyErr == nil { return s, nil } - return nil, fmt.Errorf("session %s not found", id) + if errors.Is(jsonlErr, os.ErrNotExist) && errors.Is(legacyErr, os.ErrNotExist) { + return nil, fmt.Errorf("session %s: %w", id, ErrNotFound) + } + return nil, fmt.Errorf("load session %s: %w", id, errors.Join(jsonlErr, legacyErr)) } func loadJSONL(id string) (*Session, error) { @@ -365,6 +380,9 @@ func loadJSONLFile(path, id string) (*Session, error) { if v, ok := meta["provider"].(string); ok { s.Provider = v } + if v, ok := meta["agent"].(string); ok { + s.Agent = v + } if v, ok := meta["cwd"].(string); ok { s.CWD = v } diff --git a/internal/storage/paths.go b/internal/storage/paths.go index 70583093..bf2efea4 100644 --- a/internal/storage/paths.go +++ b/internal/storage/paths.go @@ -9,11 +9,12 @@ import ( ) const ( - appName = "hawk" - envConfigDir = "HAWK_CONFIG_DIR" - envStateDir = "HAWK_STATE_DIR" - envCacheDir = "HAWK_CACHE_DIR" - projectIDHashLen = 12 + appName = "hawk" + envConfigDir = "HAWK_CONFIG_DIR" + envEyrieConfigDir = "EYRIE_CONFIG_DIR" + envStateDir = "HAWK_STATE_DIR" + envCacheDir = "HAWK_CACHE_DIR" + projectIDHashLen = 12 ) // ConfigDir returns the per-user configuration directory for Hawk. @@ -48,6 +49,12 @@ func SettingsPath() string { } func ProviderConfigPath() string { + // Eyrie owns provider routing state. Keep HAWK_CONFIG_DIR as the + // compatibility fallback, but let Eyrie's host-neutral override win when + // both variables are configured. + if dir := cleanEnvDir(envEyrieConfigDir); dir != "" { + return filepath.Join(dir, "provider.json") + } return filepath.Join(ConfigDir(), "provider.json") } diff --git a/internal/storage/paths_test.go b/internal/storage/paths_test.go index 488994e5..6185bc4b 100644 --- a/internal/storage/paths_test.go +++ b/internal/storage/paths_test.go @@ -25,6 +25,30 @@ func TestStorageDirsRespectOverrides(t *testing.T) { } } +func TestProviderConfigPathUsesEyrieOverrideWithoutMovingHawkSettings(t *testing.T) { + hawkDir := filepath.Join(t.TempDir(), "hawk") + eyrieDir := filepath.Join(t.TempDir(), "eyrie") + t.Setenv(envConfigDir, hawkDir) + t.Setenv(envEyrieConfigDir, eyrieDir) + + if got, want := ProviderConfigPath(), filepath.Join(eyrieDir, "provider.json"); got != want { + t.Fatalf("ProviderConfigPath() = %q, want EYRIE_CONFIG_DIR path %q", got, want) + } + if got, want := SettingsPath(), filepath.Join(hawkDir, "settings.json"); got != want { + t.Fatalf("SettingsPath() = %q, want HAWK_CONFIG_DIR path %q", got, want) + } +} + +func TestProviderConfigPathFallsBackToHawkOverride(t *testing.T) { + hawkDir := filepath.Join(t.TempDir(), "hawk") + t.Setenv(envConfigDir, hawkDir) + t.Setenv(envEyrieConfigDir, " ") + + if got, want := ProviderConfigPath(), filepath.Join(hawkDir, "provider.json"); got != want { + t.Fatalf("ProviderConfigPath() = %q, want HAWK_CONFIG_DIR fallback %q", got, want) + } +} + func TestProjectIDIsStableAndSafe(t *testing.T) { root := filepath.Join(t.TempDir(), "my project") diff --git a/internal/storage/testdirs.go b/internal/storage/testdirs.go index 36d9fac9..5e99d404 100644 --- a/internal/storage/testdirs.go +++ b/internal/storage/testdirs.go @@ -9,6 +9,7 @@ import ( func SetTestDirs(t *testing.T, root string) { t.Helper() t.Setenv(envConfigDir, filepath.Join(root, "config")) + t.Setenv(envEyrieConfigDir, filepath.Join(root, "eyrie")) t.Setenv(envStateDir, filepath.Join(root, "state")) t.Setenv(envCacheDir, filepath.Join(root, "cache")) } diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index df229cd5..9b847f64 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -59,15 +59,6 @@ var getEnvExemptions = map[string]bool{ "tool/web_search_searxng.go": true, // SEARXNG_URL } -// Direct eyrie/client imports are only allowed at the Hawk transport adapter edge -// and in a small number of tests that explicitly exercise provider mocks. -var eyrieClientImportExemptions = map[string]bool{ - "internal/types/client.go": true, - "internal/types/client_test.go": true, - "internal/bridge/sight/bridge.go": true, - "internal/engine/subagent_synthesis_test": true, -} - // TestNoRawPanicInInternal verifies that no non-test .go file in internal/ // calls panic() outside of init() functions. func TestNoRawPanicInInternal(t *testing.T) { @@ -193,9 +184,9 @@ func TestNoDirectOsGetenvInInternal(t *testing.T) { t.Logf("Total os.Getenv violations in internal/: %d (logged as tech debt)", violationCount) } -// TestNoDirectEyrieClientImportsOutsideAdapters verifies Hawk does not bypass -// its own transport seam by importing eyrie/client directly in production code. -func TestNoDirectEyrieClientImportsOutsideAdapters(t *testing.T) { +// TestNoDirectLowerEyrieImports verifies production Hawk code uses only +// Eyrie's stable engine facade. Tests may import lower packages for fixtures. +func TestNoDirectLowerEyrieImports(t *testing.T) { root := repoRoot(t) paths := []string{ filepath.Join(root, "internal"), @@ -206,16 +197,18 @@ func TestNoDirectEyrieClientImportsOutsideAdapters(t *testing.T) { files := parseGoFiles(t, dir) for _, pf := range files { rel := relPath(root, pf.Path) - if isExemptPackage(rel, eyrieClientImportExemptions) { + if strings.HasSuffix(rel, "_test.go") { continue } for _, imp := range pf.File.Imports { path := strings.Trim(imp.Path.Value, `"`) - if path != "github.com/GrayCodeAI/eyrie/client" { + if !strings.HasPrefix(path, "github.com/GrayCodeAI/eyrie/") || + path == "github.com/GrayCodeAI/eyrie/engine" || + strings.HasPrefix(path, "github.com/GrayCodeAI/eyrie/engine/") { continue } pos := pf.FSet.Position(imp.Pos()) - t.Fatalf("forbidden direct eyrie/client import at %s:%d; go through internal/types transport adapters instead", rel, pos.Line) + t.Fatalf("forbidden lower-level Eyrie import %q at %s:%d; use github.com/GrayCodeAI/eyrie/engine", path, rel, pos.Line) } } } @@ -247,7 +240,7 @@ func TestNoLazyProviderConstructionInHawk(t *testing.T) { return true } pos := pf.FSet.Position(call.Pos()) - t.Fatalf("forbidden eyrie lazy provider construction at %s:%d; use runtime.ResolveChatTransport", rel, pos.Line) + t.Fatalf("forbidden eyrie lazy provider construction at %s:%d; use the eyrie/engine facade", rel, pos.Line) return true }) } @@ -336,7 +329,7 @@ var legacySessionFields = []string{ "Memory", "YaadBridge", "EnhancedMemory", "Cascade", "Lifecycle", "Reflector", "CostTracker", "Autonomy", "Sandbox", "Plan", "Beliefs", "Critic", "Backtrack", - "Limits", "Trajectory", "Shadow", "Snapshots", "ConvoDAG", + "Limits", "Trajectory", "Shadow", "Snapshots", "ConversationGraph", "Sleeptime", "Activity", "SkillDistiller", "Tracer", "LintLoop", "TestLoop", "FileMentions", "ResponseCache", "Pipeline", "Files", "Steering", "RateLimiter", "AgentsAccum", @@ -346,7 +339,7 @@ var legacySessionFields = []string{ "AutoCompactThresholdPct", "ContextWindowCached", "AutoCompactor", "persistID", "lastPromptTokens", "lastCompletionTokens", "checkpointMgr", "OnCompaction", - "Router", "apiKeys", "provider", "model", "system", + "Router", "provider", "model", "system", "Cost", "ContainerExecutor", "ContainerRequired", "DeploymentRouting", } diff --git a/internal/tool/safety.go b/internal/tool/safety.go index 9230653e..37395208 100644 --- a/internal/tool/safety.go +++ b/internal/tool/safety.go @@ -237,17 +237,23 @@ var blockedBasenames = []string{ "credentials.xml", } +func matchesResolvedPath(cleanPath, candidate string) bool { + resolved := candidate + if canonical, err := ResolvePath(candidate); err == nil { + resolved = canonical + } + return cleanPath == filepath.Clean(resolved) +} + // IsSensitivePath returns a non-empty reason when path points to a file // that should be blocked for security. The path is cleaned and, when // possible, resolved through symlinks before checking. func IsSensitivePath(path string) string { - // Resolve to absolute + follow symlinks when possible. + // Resolve to absolute + follow symlinks when possible, including a + // symlinked parent for a file that does not exist yet (the Write case). resolved := path - if abs, err := filepath.Abs(path); err == nil { - resolved = abs - } - if evaled, err := filepath.EvalSymlinks(resolved); err == nil { - resolved = evaled + if canonical, err := ResolvePath(path); err == nil { + resolved = canonical } clean := filepath.Clean(resolved) @@ -268,22 +274,21 @@ func IsSensitivePath(path string) string { } } - providerPath := filepath.Clean(storage.ProviderConfigPath()) - if clean == providerPath { + if matchesResolvedPath(clean, storage.ProviderConfigPath()) { return "access to provider.json is blocked for security (API credentials)" } if cfgDir := strings.TrimSpace(env.Getenv("HAWK_CONFIG_DIR")); cfgDir != "" { - customProv := filepath.Clean(filepath.Join(cfgDir, "provider.json")) - if clean == customProv { + customProv := filepath.Join(cfgDir, "provider.json") + if matchesResolvedPath(clean, customProv) { return "access to provider.json is blocked for security (API credentials)" } - customEnv := filepath.Clean(filepath.Join(cfgDir, "env")) - if clean == customEnv { + customEnv := filepath.Join(cfgDir, "env") + if matchesResolvedPath(clean, customEnv) { return "access to hawk env file is blocked for security (API keys)" } - customDotEnv := filepath.Clean(filepath.Join(cfgDir, ".env")) - if clean == customDotEnv { + customDotEnv := filepath.Join(cfgDir, ".env") + if matchesResolvedPath(clean, customDotEnv) { return "access to hawk .env is blocked for security (API keys)" } } @@ -334,16 +339,53 @@ func commandPathSeparators(r rune) bool { return false } +func expandCommandPathVariables(command string) string { + command = strings.ReplaceAll(command, `\ `, " ") + for _, item := range []struct { + name string + value string + }{ + {name: "HOME", value: home.Dir()}, + {name: "HAWK_CONFIG_DIR", value: strings.TrimSpace(env.Getenv("HAWK_CONFIG_DIR"))}, + {name: "EYRIE_CONFIG_DIR", value: strings.TrimSpace(env.Getenv("EYRIE_CONFIG_DIR"))}, + } { + if item.value == "" { + continue + } + command = strings.ReplaceAll(command, "${"+item.name+"}", item.value) + command = strings.ReplaceAll(command, "$"+item.name, item.value) + } + return strings.NewReplacer(`"`, "", `'`, "").Replace(command) +} + // CommandReferencesSensitivePath returns a non-empty reason when a shell // command string references a credential file that the file tools already // block via IsSensitivePath (SSH keys, ~/.aws/credentials, .env, provider // configs, …). Without this, Bash is a trivial bypass of that protection -// ("cat ~/.ssh/id_rsa"). Matching is string-based (suffix/basename) so it -// stays cheap; it deliberately does not hit the filesystem. +// ("cat ~/.ssh/id_rsa"). Suffix/basename checks cover common forms, while +// IsSensitivePath keeps configured and symlinked provider paths aligned with +// the file tools. func CommandReferencesSensitivePath(command string) string { + // Do not try to emulate every shell parameter-expansion form here. An + // EYRIE_CONFIG_DIR reference is itself sensitive because the variable + // identifies the credential-bearing provider-state directory. This also + // closes modifier forms such as ${EYRIE_CONFIG_DIR%/}/provider.json. + if strings.Contains(command, "EYRIE_CONFIG_DIR") { + return "command references EYRIE_CONFIG_DIR, blocked for security" + } if !strings.ContainsAny(command, "/.") { return "" } + command = expandCommandPathVariables(command) + configuredPaths := []string{storage.ProviderConfigPath()} + if cfgDir := strings.TrimSpace(env.Getenv("HAWK_CONFIG_DIR")); cfgDir != "" { + configuredPaths = append(configuredPaths, filepath.Join(cfgDir, "provider.json"), filepath.Join(cfgDir, "env"), filepath.Join(cfgDir, ".env")) + } + for _, candidate := range configuredPaths { + if candidate != "" && strings.Contains(command, candidate) { + return "command references a configured credential path, blocked for security" + } + } homeDir := home.Dir() for _, tok := range strings.FieldsFunc(command, commandPathSeparators) { tok = strings.Trim(tok, ",:") @@ -359,6 +401,12 @@ func CommandReferencesSensitivePath(command string) string { tok = homeDir + tok[len("$HOME"):] } } + // Keep Bash aligned with the Read/Edit/Write path policy, including a + // provider.json rooted at EYRIE_CONFIG_DIR. ResolvePath also handles + // relative custom config directories and symlinked parents. + if reason := IsSensitivePath(tok); reason != "" { + return "command references a sensitive path: " + reason + } for _, suffix := range blockedPathSuffixes { if strings.HasSuffix(tok, suffix) || tok == suffix[1:] { return fmt.Sprintf("command references %s, blocked for security", suffix) diff --git a/internal/tool/safety_test.go b/internal/tool/safety_test.go index dde59ea9..fbdf487a 100644 --- a/internal/tool/safety_test.go +++ b/internal/tool/safety_test.go @@ -1,6 +1,8 @@ package tool import ( + "context" + "encoding/json" "os" "path/filepath" "strings" @@ -267,6 +269,60 @@ func TestIsSensitivePath_HawkConfigDir(t *testing.T) { } } +func TestIsSensitivePath_EyrieConfigDirTakesPrecedence(t *testing.T) { + hawkDir := filepath.Join(t.TempDir(), "hawk") + eyrieDir := filepath.Join(t.TempDir(), "eyrie") + t.Setenv("HAWK_CONFIG_DIR", hawkDir) + t.Setenv("EYRIE_CONFIG_DIR", eyrieDir) + + if reason := IsSensitivePath(filepath.Join(eyrieDir, "provider.json")); reason == "" { + t.Fatal("expected EYRIE_CONFIG_DIR/provider.json to be blocked") + } + if reason := IsSensitivePath(filepath.Join(hawkDir, "settings.json")); reason != "" { + t.Fatalf("expected Hawk settings path to remain allowed, got %q", reason) + } +} + +func TestFileToolsBlockEyrieProviderConfig(t *testing.T) { + eyrieDir := filepath.Join(t.TempDir(), "eyrie") + t.Setenv("EYRIE_CONFIG_DIR", eyrieDir) + providerPath := filepath.Join(eyrieDir, "provider.json") + + readInput, _ := json.Marshal(map[string]string{"path": providerPath}) + editInput, _ := json.Marshal(map[string]string{ + "path": providerPath, "old_str": "old", "new_str": "new", + }) + writeInput, _ := json.Marshal(map[string]string{ + "path": providerPath, "content": "safe routing metadata", + }) + tests := []struct { + name string + run func() error + }{ + {name: "Read", run: func() error { + _, err := (FileReadTool{}).Execute(context.Background(), readInput) + return err + }}, + {name: "Edit", run: func() error { + _, err := (FileEditTool{}).Execute(context.Background(), editInput) + return err + }}, + {name: "Write", run: func() error { + _, err := (FileWriteTool{}).Execute(context.Background(), writeInput) + return err + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.run() + if err == nil || !strings.Contains(err.Error(), "blocked") { + t.Fatalf("%s error = %v, want sensitive-path block", tt.name, err) + } + }) + } +} + func TestIsSensitivePath_HawkConfigDirEnv(t *testing.T) { cfgDir := t.TempDir() t.Setenv("HAWK_CONFIG_DIR", cfgDir) @@ -744,6 +800,31 @@ func TestCommandReferencesSensitivePath(t *testing.T) { } } +func TestCommandReferencesSensitivePath_EyrieConfigDir(t *testing.T) { + eyrieDir := filepath.Join(t.TempDir(), "eyrie config with spaces") + t.Setenv("EYRIE_CONFIG_DIR", eyrieDir) + + commands := []string{ + `cat "` + filepath.Join(eyrieDir, "provider.json") + `"`, + `cat "$EYRIE_CONFIG_DIR/provider.json"`, + `cat "${EYRIE_CONFIG_DIR}/provider.json"`, + `cat "${EYRIE_CONFIG_DIR%/}/provider.json"`, + `printf '%s\n' "$EYRIE_CONFIG_DIR"`, + "cat " + strings.ReplaceAll(filepath.Join(eyrieDir, "provider.json"), " ", `\ `), + } + for _, command := range commands { + if reason := CommandReferencesSensitivePath(command); reason == "" { + t.Fatalf("CommandReferencesSensitivePath(%q) = empty, want blocked", command) + } + if !IsSuspicious(command) { + t.Fatalf("IsSuspicious(%q) = false, want Bash prompt", command) + } + if !isHardDeny(command) { + t.Fatalf("isHardDeny(%q) = false, want Bash block", command) + } + } +} + func TestBashSensitivePathIntegration(t *testing.T) { if !IsSuspicious("cat ~/.ssh/id_rsa") { t.Error("IsSuspicious should flag reads of SSH private keys") diff --git a/internal/types/client.go b/internal/types/client.go index 86bb5c5d..fa0c98a5 100644 --- a/internal/types/client.go +++ b/internal/types/client.go @@ -1,25 +1,27 @@ package types -import ( - "context" - "sync" +import "context" - "github.com/GrayCodeAI/eyrie/client" -) +// ContentPart represents a provider-neutral multimodal message part. Hawk owns +// this conversation shape; the Eyrie engine adapter translates it at the +// transport boundary. +type ContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *ImageURLPart `json:"image_url,omitempty"` + InputAudio *InputAudioPart `json:"input_audio,omitempty"` +} -type ( - ContentPart = client.ContentPart - ImageURLPart = client.ImageURLPart - InputAudioPart = client.InputAudioPart -) +// ImageURLPart describes an image URL or data URI. +type ImageURLPart struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} -// ClientConfig is Hawk-owned client construction config at the transport edge. -type ClientConfig struct { - Provider string `json:"provider,omitempty"` - APIKey string `json:"-"` - BaseURL string `json:"base_url,omitempty"` - Model string `json:"model,omitempty"` - MaxRetries int `json:"max_retries,omitempty"` +// InputAudioPart describes base64-encoded audio content. +type InputAudioPart struct { + Data string `json:"data"` + Format string `json:"format"` } // ChatProvider is Hawk's transport-provider interface. @@ -34,7 +36,6 @@ type ChatProvider interface { type ChatClient interface { Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) StreamChatContinue(ctx context.Context, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) - SetAPIKey(provider, apiKey string) } // ResponseFormat specifies the desired output format for a Hawk runtime request. @@ -57,7 +58,7 @@ type EyrieTool struct { Parameters map[string]interface{} `json:"parameters"` } -// ChatOptions holds Hawk-owned request options for a runtime chat call. +// ChatOptions holds Hawk-owned request options for an engine chat call. type ChatOptions struct { Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` @@ -104,6 +105,13 @@ type ContinuationConfig struct { MaxTotalTokens int } +// DefaultContinuationConfig is Hawk's agent-loop continuation policy. Eyrie +// receives these limits through its engine request rather than owning the +// product policy. +func DefaultContinuationConfig() ContinuationConfig { + return ContinuationConfig{MaxContinuations: 3, MaxTotalTokens: 32000} +} + // ToolCall is Hawk's runtime tool invocation DTO. type ToolCall struct { ID string `json:"id,omitempty"` @@ -128,29 +136,40 @@ type EyrieUsage struct { ThinkingTokens int `json:"thinking_tokens,omitempty"` } +// ResolvedRoute is Hawk's view of the concrete provider/model route selected +// by the provider engine. Keeping this projection Hawk-owned prevents Eyrie's +// transport DTOs from leaking into product state and observability payloads. +type ResolvedRoute struct { + Provider string `json:"provider"` + Model string `json:"model"` + DeploymentRouting bool `json:"deployment_routing,omitempty"` +} + // EyrieResponse is Hawk's runtime chat response DTO. type EyrieResponse struct { - Content string `json:"content"` - Thinking string `json:"thinking,omitempty"` - Usage *EyrieUsage `json:"usage,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - RequestID string `json:"request_id,omitempty"` - OrganizationID string `json:"organization_id,omitempty"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + RequestID string `json:"request_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + Route *ResolvedRoute `json:"route,omitempty"` } // EyrieStreamEvent is Hawk's runtime stream event DTO. type EyrieStreamEvent struct { - Type string `json:"type"` - Content string `json:"content,omitempty"` - ToolCall *ToolCall `json:"tool_call,omitempty"` - Thinking string `json:"thinking,omitempty"` - Error string `json:"error,omitempty"` - RequestID string `json:"request_id,omitempty"` - Usage *EyrieUsage `json:"usage,omitempty"` - StopReason string `json:"stop_reason,omitempty"` - TTFTms int `json:"ttft_ms,omitempty"` - TTFT int `json:"ttft,omitempty"` + Type string `json:"type"` + Content string `json:"content,omitempty"` + ToolCall *ToolCall `json:"tool_call,omitempty"` + Thinking string `json:"thinking,omitempty"` + Error string `json:"error,omitempty"` + RequestID string `json:"request_id,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + TTFTms int `json:"ttft_ms,omitempty"` + TTFT int `json:"ttft,omitempty"` + Route *ResolvedRoute `json:"route,omitempty"` } // StreamResult wraps a Hawk-owned streaming response with cleanup. @@ -160,6 +179,12 @@ type StreamResult struct { cancel context.CancelFunc } +// NewStreamResult constructs a Hawk-owned stream result around an engine +// event channel. The cancel function is optional and must be idempotent. +func NewStreamResult(events <-chan EyrieStreamEvent, requestID string, cancel context.CancelFunc) *StreamResult { + return &StreamResult{Events: events, RequestID: requestID, cancel: cancel} +} + // Close stops the stream and releases resources. func (sr *StreamResult) Close() { if sr != nil && sr.cancel != nil { @@ -168,7 +193,7 @@ func (sr *StreamResult) Close() { } // EyrieMessage is Hawk's runtime conversation DTO. -// It intentionally mirrors the provider runtime shape while remaining Hawk-owned. +// It intentionally mirrors the engine boundary shape while remaining Hawk-owned. type EyrieMessage struct { Role string `json:"role"` Content string `json:"content,omitempty"` @@ -178,511 +203,3 @@ type EyrieMessage struct { ToolUse []ToolCall `json:"tool_use,omitempty"` ToolResults []ToolResult `json:"tool_results,omitempty"` } - -// EyrieClient adapts eyrie's client to Hawk-owned message DTOs. -type EyrieClient struct { - inner *client.EyrieClient - providerName string -} - -type providerAdapter struct { - inner client.Provider -} - -func DefaultContinuationConfig() ContinuationConfig { - cfg := client.DefaultContinuationConfig() - return ContinuationConfig{ - MaxContinuations: cfg.MaxContinuations, - MaxTotalTokens: cfg.MaxTotalTokens, - } -} - -func DetectProvider() string { - return client.DetectProvider() -} - -func RegisterDynamicProvider(name, baseURL, apiKeyEnv string) error { - return client.RegisterDynamicProvider(name, baseURL, apiKeyEnv) -} - -func NewClient(cfg *ClientConfig) *EyrieClient { - providerName := "" - if cfg != nil { - providerName = cfg.Provider - } - return &EyrieClient{ - inner: client.Client(ToClientConfig(cfg)), - providerName: providerName, - } -} - -func ParseInlineToolCalls(content string) (string, []ToolCall) { - text, calls := client.ParseInlineToolCalls(content) - return text, FromClientToolCalls(calls) -} - -func WrapClientProvider(p client.Provider) ChatProvider { - if p == nil { - return nil - } - return &providerAdapter{inner: p} -} - -func StreamChatWithContinuation(ctx context.Context, p ChatProvider, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { - if p == nil { - return nil, nil - } - if adapted, ok := p.(*providerAdapter); ok { - stream, err := client.StreamChatWithContinuation(ctx, adapted.inner, ToClientMessages(messages), ToClientChatOptions(opts), ToClientContinuationConfig(cfg)) - if err != nil { - return nil, err - } - return FromClientStreamResult(stream), nil - } - stream, err := p.StreamChat(ctx, messages, opts) - if err != nil { - return nil, err - } - return stream, nil -} - -func (c *EyrieClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - resp, err := c.inner.Chat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) - if err != nil { - return nil, err - } - return FromClientResponse(resp), nil -} - -func (c *EyrieClient) StreamChatContinue(ctx context.Context, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { - stream, err := c.inner.StreamChatContinue(ctx, ToClientMessages(messages), ToClientChatOptions(opts), ToClientContinuationConfig(cfg)) - if err != nil { - return nil, err - } - return FromClientStreamResult(stream), nil -} - -func (c *EyrieClient) SetAPIKey(provider, apiKey string) { - c.inner.SetAPIKey(provider, apiKey) -} - -func (c *EyrieClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { - stream, err := c.inner.StreamChat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) - if err != nil { - return nil, err - } - return FromClientStreamResult(stream), nil -} - -func (c *EyrieClient) Ping(ctx context.Context) error { - return c.inner.Ping(ctx, "") -} - -func (c *EyrieClient) Name() string { - return c.providerName -} - -func (c *EyrieClient) GetProviders() []string { - return c.inner.GetProviders() -} - -func (p *providerAdapter) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - resp, err := p.inner.Chat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) - if err != nil { - return nil, err - } - return FromClientResponse(resp), nil -} - -func (p *providerAdapter) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { - stream, err := p.inner.StreamChat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) - if err != nil { - return nil, err - } - return FromClientStreamResult(stream), nil -} - -func (p *providerAdapter) Ping(ctx context.Context) error { - return p.inner.Ping(ctx) -} - -func (p *providerAdapter) Name() string { - return p.inner.Name() -} - -func (p *providerAdapter) SetAPIKey(provider, apiKey string) { - if setter, ok := p.inner.(interface{ SetAPIKey(string, string) }); ok { - setter.SetAPIKey(provider, apiKey) - } -} - -// ToClientConfig converts Hawk-owned transport config into the provider-runtime shape. -func ToClientConfig(cfg *ClientConfig) *client.EyrieConfig { - if cfg == nil { - return nil - } - return &client.EyrieConfig{ - Provider: cfg.Provider, - APIKey: cfg.APIKey, - BaseURL: cfg.BaseURL, - Model: cfg.Model, - MaxRetries: cfg.MaxRetries, - } -} - -// ToClientResponseFormat converts a Hawk runtime response format into the provider-runtime shape. -func ToClientResponseFormat(format *ResponseFormat) *client.ResponseFormat { - if format == nil { - return nil - } - return &client.ResponseFormat{ - Type: format.Type, - Schema: format.Schema, - } -} - -// ToClientToolChoiceOption converts a Hawk runtime tool choice into the provider-runtime shape. -func ToClientToolChoiceOption(choice *ToolChoiceOption) *client.ToolChoiceOption { - if choice == nil { - return nil - } - return &client.ToolChoiceOption{ - Type: choice.Type, - Name: choice.Name, - DisableParallelToolUse: choice.DisableParallelToolUse, - } -} - -// ToClientEyrieTool converts a Hawk runtime tool definition into the provider-runtime shape. -func ToClientEyrieTool(tool EyrieTool) client.EyrieTool { - return client.EyrieTool{ - Name: tool.Name, - Description: tool.Description, - Parameters: tool.Parameters, - } -} - -// ToClientEyrieTools converts Hawk runtime tool definitions into provider-runtime tool definitions. -func ToClientEyrieTools(tools []EyrieTool) []client.EyrieTool { - if len(tools) == 0 { - return nil - } - out := make([]client.EyrieTool, len(tools)) - for i, tool := range tools { - out[i] = ToClientEyrieTool(tool) - } - return out -} - -// ToClientChatOptions converts Hawk runtime chat options into the provider-runtime shape. -func ToClientChatOptions(opts ChatOptions) client.ChatOptions { - return client.ChatOptions{ - Provider: opts.Provider, - Model: opts.Model, - Temperature: opts.Temperature, - MaxTokens: opts.MaxTokens, - Stream: opts.Stream, - Tools: ToClientEyrieTools(opts.Tools), - System: opts.System, - EnableCaching: opts.EnableCaching, - ResponseFormat: ToClientResponseFormat(opts.ResponseFormat), - ReasoningEffort: opts.ReasoningEffort, - ThinkingBudgetTokens: opts.ThinkingBudgetTokens, - ThinkingMode: opts.ThinkingMode, - ThinkingDisplay: opts.ThinkingDisplay, - GLMThinkingEnabled: opts.GLMThinkingEnabled, - VirtualKeyID: opts.VirtualKeyID, - KimiContextCacheID: opts.KimiContextCacheID, - KimiCacheResetTTL: opts.KimiCacheResetTTL, - TopP: opts.TopP, - TopK: opts.TopK, - StopSequences: opts.StopSequences, - ToolChoice: ToClientToolChoiceOption(opts.ToolChoice), - MetadataUserID: opts.MetadataUserID, - ServiceTier: opts.ServiceTier, - OutputEffort: opts.OutputEffort, - OutputSchema: opts.OutputSchema, - PresencePenalty: opts.PresencePenalty, - FrequencyPenalty: opts.FrequencyPenalty, - N: opts.N, - LogProbs: opts.LogProbs, - TopLogProbs: opts.TopLogProbs, - Seed: opts.Seed, - Store: opts.Store, - Metadata: opts.Metadata, - Modalities: opts.Modalities, - AudioConfig: opts.AudioConfig, - Prediction: opts.Prediction, - WebSearchOptions: opts.WebSearchOptions, - } -} - -// ToClientContinuationConfig converts Hawk runtime continuation settings into the provider-runtime shape. -func ToClientContinuationConfig(cfg ContinuationConfig) client.ContinuationConfig { - return client.ContinuationConfig{ - MaxContinuations: cfg.MaxContinuations, - MaxTotalTokens: cfg.MaxTotalTokens, - } -} - -// ToClientToolCall converts a Hawk runtime tool call into the provider-runtime shape. -func ToClientToolCall(tc ToolCall) client.ToolCall { - return client.ToolCall{ - ID: tc.ID, - Name: tc.Name, - Arguments: tc.Arguments, - } -} - -// ToClientToolCalls converts Hawk runtime tool calls into provider-runtime tool calls. -func ToClientToolCalls(calls []ToolCall) []client.ToolCall { - if len(calls) == 0 { - return nil - } - out := make([]client.ToolCall, len(calls)) - for i, tc := range calls { - out[i] = ToClientToolCall(tc) - } - return out -} - -// FromClientToolCall converts a provider-runtime tool call into Hawk's runtime shape. -func FromClientToolCall(tc client.ToolCall) ToolCall { - return ToolCall{ - ID: tc.ID, - Name: tc.Name, - Arguments: tc.Arguments, - } -} - -// FromClientToolCalls converts provider-runtime tool calls into Hawk runtime tool calls. -func FromClientToolCalls(calls []client.ToolCall) []ToolCall { - if len(calls) == 0 { - return nil - } - out := make([]ToolCall, len(calls)) - for i, tc := range calls { - out[i] = FromClientToolCall(tc) - } - return out -} - -// ToClientToolResult converts a Hawk runtime tool result into the provider-runtime shape. -func ToClientToolResult(tr ToolResult) client.ToolResult { - return client.ToolResult{ - ToolUseID: tr.ToolUseID, - Content: tr.Content, - IsError: tr.IsError, - } -} - -// ToClientToolResults converts Hawk runtime tool results into provider-runtime tool results. -func ToClientToolResults(results []ToolResult) []client.ToolResult { - if len(results) == 0 { - return nil - } - out := make([]client.ToolResult, len(results)) - for i, tr := range results { - out[i] = ToClientToolResult(tr) - } - return out -} - -// FromClientToolResult converts a provider-runtime tool result into Hawk's runtime shape. -func FromClientToolResult(tr client.ToolResult) ToolResult { - return ToolResult{ - ToolUseID: tr.ToolUseID, - Content: tr.Content, - IsError: tr.IsError, - } -} - -// FromClientToolResults converts provider-runtime tool results into Hawk runtime tool results. -func FromClientToolResults(results []client.ToolResult) []ToolResult { - if len(results) == 0 { - return nil - } - out := make([]ToolResult, len(results)) - for i, tr := range results { - out[i] = FromClientToolResult(tr) - } - return out -} - -// ToClientUsage converts a Hawk runtime usage payload into the provider-runtime shape. -func ToClientUsage(usage *EyrieUsage) *client.EyrieUsage { - if usage == nil { - return nil - } - return &client.EyrieUsage{ - PromptTokens: usage.PromptTokens, - CompletionTokens: usage.CompletionTokens, - TotalTokens: usage.TotalTokens, - CacheCreationTokens: usage.CacheCreationTokens, - CacheReadTokens: usage.CacheReadTokens, - ThinkingTokens: usage.ThinkingTokens, - } -} - -// FromClientUsage converts a provider-runtime usage payload into Hawk's runtime shape. -func FromClientUsage(usage *client.EyrieUsage) *EyrieUsage { - if usage == nil { - return nil - } - return &EyrieUsage{ - PromptTokens: usage.PromptTokens, - CompletionTokens: usage.CompletionTokens, - TotalTokens: usage.TotalTokens, - CacheCreationTokens: usage.CacheCreationTokens, - CacheReadTokens: usage.CacheReadTokens, - ThinkingTokens: usage.ThinkingTokens, - } -} - -// FromClientResponse converts a provider-runtime response into Hawk's runtime shape. -func FromClientResponse(resp *client.EyrieResponse) *EyrieResponse { - if resp == nil { - return nil - } - return &EyrieResponse{ - Content: resp.Content, - Thinking: resp.Thinking, - Usage: FromClientUsage(resp.Usage), - ToolCalls: FromClientToolCalls(resp.ToolCalls), - FinishReason: resp.FinishReason, - RequestID: resp.RequestID, - OrganizationID: resp.OrganizationID, - } -} - -// ToClientStreamEvent converts a Hawk runtime stream event into the provider-runtime shape. -func ToClientStreamEvent(ev EyrieStreamEvent) client.EyrieStreamEvent { - var toolCall *client.ToolCall - if ev.ToolCall != nil { - tc := ToClientToolCall(*ev.ToolCall) - toolCall = &tc - } - return client.EyrieStreamEvent{ - Type: ev.Type, - Content: ev.Content, - ToolCall: toolCall, - Thinking: ev.Thinking, - Error: ev.Error, - RequestID: ev.RequestID, - Usage: ToClientUsage(ev.Usage), - StopReason: ev.StopReason, - TTFTms: ev.TTFTms, - TTFT: ev.TTFT, - } -} - -// FromClientStreamEvent converts a provider-runtime stream event into Hawk's runtime shape. -func FromClientStreamEvent(ev client.EyrieStreamEvent) EyrieStreamEvent { - var toolCall *ToolCall - if ev.ToolCall != nil { - tc := FromClientToolCall(*ev.ToolCall) - toolCall = &tc - } - return EyrieStreamEvent{ - Type: ev.Type, - Content: ev.Content, - ToolCall: toolCall, - Thinking: ev.Thinking, - Error: ev.Error, - RequestID: ev.RequestID, - Usage: FromClientUsage(ev.Usage), - StopReason: ev.StopReason, - TTFTms: ev.TTFTms, - TTFT: ev.TTFT, - } -} - -// FromClientStreamResult converts a provider-runtime stream result into Hawk's runtime shape. -func FromClientStreamResult(stream *client.StreamResult) *StreamResult { - if stream == nil { - return nil - } - out := make(chan EyrieStreamEvent, 64) - ctx, cancel := context.WithCancel(context.Background()) - var closeOnce sync.Once - closeFn := func() { - closeOnce.Do(func() { - cancel() - stream.Close() - }) - } - go func() { - defer close(out) - defer closeFn() - for { - select { - case <-ctx.Done(): - return - case ev, ok := <-stream.Events: - if !ok { - return - } - select { - case out <- FromClientStreamEvent(ev): - case <-ctx.Done(): - return - } - } - } - }() - return &StreamResult{ - Events: out, - RequestID: stream.RequestID, - cancel: closeFn, - } -} - -// ToClientMessage converts a Hawk runtime message into the provider-runtime shape. -func ToClientMessage(msg EyrieMessage) client.EyrieMessage { - return client.EyrieMessage{ - Role: msg.Role, - Content: msg.Content, - Thinking: msg.Thinking, - ContentParts: msg.ContentParts, - Images: msg.Images, - ToolUse: ToClientToolCalls(msg.ToolUse), - ToolResults: ToClientToolResults(msg.ToolResults), - } -} - -// ToClientMessages converts Hawk runtime messages into provider-runtime messages. -func ToClientMessages(messages []EyrieMessage) []client.EyrieMessage { - if len(messages) == 0 { - return nil - } - out := make([]client.EyrieMessage, len(messages)) - for i, msg := range messages { - out[i] = ToClientMessage(msg) - } - return out -} - -// FromClientMessage converts a provider-runtime message into Hawk's runtime shape. -func FromClientMessage(msg client.EyrieMessage) EyrieMessage { - return EyrieMessage{ - Role: msg.Role, - Content: msg.Content, - Thinking: msg.Thinking, - ContentParts: msg.ContentParts, - Images: msg.Images, - ToolUse: FromClientToolCalls(msg.ToolUse), - ToolResults: FromClientToolResults(msg.ToolResults), - } -} - -// FromClientMessages converts provider-runtime messages into Hawk runtime messages. -func FromClientMessages(messages []client.EyrieMessage) []EyrieMessage { - if len(messages) == 0 { - return nil - } - out := make([]EyrieMessage, len(messages)) - for i, msg := range messages { - out[i] = FromClientMessage(msg) - } - return out -} diff --git a/internal/types/client_test.go b/internal/types/client_test.go index ec6b3014..b0e43d8b 100644 --- a/internal/types/client_test.go +++ b/internal/types/client_test.go @@ -1,127 +1,57 @@ package types import ( - "reflect" + "context" + "encoding/json" "testing" - - "github.com/GrayCodeAI/eyrie/client" ) -func TestEyrieMessageClientRoundTrip(t *testing.T) { - in := []EyrieMessage{ - { - Role: "assistant", - Content: "done", - Thinking: "reasoning", - Images: []string{"data:image/png;base64,abc"}, - ToolUse: []ToolCall{{ - ID: "tool_1", - Name: "Read", - Arguments: map[string]interface{}{ - "path": "main.go", - }, - }}, - }, - { - Role: "user", - ToolResults: []ToolResult{{ - ToolUseID: "tool_1", - Content: "package main", - }}, +func TestContentPartJSONContract(t *testing.T) { + in := EyrieMessage{ + Role: "user", + ContentParts: []ContentPart{ + {Type: "text", Text: "hello"}, + {Type: "image_url", ImageURL: &ImageURLPart{URL: "data:image/png;base64,abc", Detail: "high"}}, + {Type: "input_audio", InputAudio: &InputAudioPart{Data: "audio", Format: "wav"}}, }, } - got := FromClientMessages(ToClientMessages(in)) - if !reflect.DeepEqual(got, in) { - t.Fatalf("round trip mismatch:\n got: %#v\nwant: %#v", got, in) + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("Marshal() error = %v", err) } -} - -func TestToolCallClientRoundTrip(t *testing.T) { - in := []ToolCall{{ - ID: "tool_1", - Name: "Read", - Arguments: map[string]interface{}{ - "path": "main.go", - }, - }} - - got := FromClientToolCalls(ToClientToolCalls(in)) - if !reflect.DeepEqual(got, in) { - t.Fatalf("tool call round trip mismatch:\n got: %#v\nwant: %#v", got, in) + var got EyrieMessage + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) } -} - -func TestToolResultClientRoundTrip(t *testing.T) { - in := []ToolResult{{ - ToolUseID: "tool_1", - Content: "ok", - IsError: true, - }} - - got := FromClientToolResults(ToClientToolResults(in)) - if !reflect.DeepEqual(got, in) { - t.Fatalf("tool result round trip mismatch:\n got: %#v\nwant: %#v", got, in) + if len(got.ContentParts) != 3 || got.ContentParts[0].Text != "hello" { + t.Fatalf("ContentParts = %#v", got.ContentParts) } -} - -func TestFromClientMessagePreservesContentParts(t *testing.T) { - in := client.EyrieMessage{ - Role: "user", - ContentParts: []client.ContentPart{{ - Type: "text", - Text: "hello", - }}, + if got.ContentParts[1].ImageURL == nil || got.ContentParts[1].ImageURL.Detail != "high" { + t.Fatalf("image part = %#v", got.ContentParts[1]) } - - got := FromClientMessage(in) - if len(got.ContentParts) != 1 || got.ContentParts[0].Text != "hello" { - t.Fatalf("ContentParts = %#v, want text part", got.ContentParts) + if got.ContentParts[2].InputAudio == nil || got.ContentParts[2].InputAudio.Format != "wav" { + t.Fatalf("audio part = %#v", got.ContentParts[2]) } } -func TestToClientConfig(t *testing.T) { - cfg := &ClientConfig{ - Provider: "anthropic", - APIKey: "secret", - BaseURL: "https://proxy.example", - Model: "claude-sonnet", - MaxRetries: 3, - } - - got := ToClientConfig(cfg) - if got == nil { - t.Fatal("expected non-nil client config") - } - if got.Provider != cfg.Provider || got.APIKey != cfg.APIKey || got.BaseURL != cfg.BaseURL || got.Model != cfg.Model || got.MaxRetries != cfg.MaxRetries { - t.Fatalf("ToClientConfig = %#v, want %#v", got, cfg) +func TestDefaultContinuationConfig(t *testing.T) { + got := DefaultContinuationConfig() + if got.MaxContinuations != 3 || got.MaxTotalTokens != 32000 { + t.Fatalf("DefaultContinuationConfig() = %#v", got) } } -func TestWrapClientProvider(t *testing.T) { - mock := client.NewMockProvider(client.MockModeFixed) - mock.Response = "wrapped" - - provider := WrapClientProvider(mock) - if provider == nil { - t.Fatal("expected non-nil provider adapter") - } - if provider.Name() != "mock" { - t.Fatalf("Name() = %q, want mock", provider.Name()) +func TestStreamResultCloseIsOptionalAndIdempotentCompatible(t *testing.T) { + closed := 0 + result := NewStreamResult(nil, "request-1", func() { closed++ }) + if result.RequestID != "request-1" { + t.Fatalf("RequestID = %q", result.RequestID) } - - resp, err := provider.Chat(t.Context(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{}) - if err != nil { - t.Fatalf("Chat error: %v", err) - } - if resp.Content != "wrapped" { - t.Fatalf("Content = %q, want wrapped", resp.Content) - } -} - -func TestNewClientPreservesProviderName(t *testing.T) { - c := NewClient(&ClientConfig{Provider: "openai"}) - if c.Name() != "openai" { - t.Fatalf("Name() = %q, want openai", c.Name()) + result.Close() + if closed != 1 { + t.Fatalf("close calls = %d, want 1", closed) } + (*StreamResult)(nil).Close() + NewStreamResult(nil, "", context.CancelFunc(nil)).Close() } diff --git a/scripts/check-eyrie-client-imports.sh b/scripts/check-eyrie-client-imports.sh index 13cc9676..0625fdea 100755 --- a/scripts/check-eyrie-client-imports.sh +++ b/scripts/check-eyrie-client-imports.sh @@ -4,22 +4,24 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -violations="$( - git grep -n 'github\.com/GrayCodeAI/eyrie/client' -- '*.go' \ - ':(exclude)external/**' \ - ':(exclude)internal/types/client.go' \ - ':(exclude)internal/types/client_test.go' \ - ':(exclude)internal/bridge/sight/bridge.go' \ - ':(exclude)internal/engine/subagent_synthesis_test.go' \ - ':(exclude)internal/testaudit/audit_test.go' || true -)" +if command -v rg >/dev/null 2>&1; then + violations="$( + rg -n '"github\.com/GrayCodeAI/eyrie/client(?:/[^\"]*)?"' \ + --glob '*.go' --glob '!*_test.go' --glob '!external/**' . || true + )" +else + violations="$( + grep -RInE --include='*.go' --exclude='*_test.go' --exclude-dir=external \ + '"github\.com/GrayCodeAI/eyrie/client(/[^\"]*)?"' . || true + )" +fi if [[ -n "${violations}" ]]; then echo "forbidden direct imports of github.com/GrayCodeAI/eyrie/client found:" echo "${violations}" echo - echo "hawk production code must go through internal/types transport adapters instead of importing eyrie/client directly" + echo "Hawk production code must go through github.com/GrayCodeAI/eyrie/engine" exit 1 fi -echo "eyrie/client boundary guard passed" +echo "eyrie/client boundary guard passed (zero production imports)" diff --git a/scripts/check-eyrie-engine-boundary.sh b/scripts/check-eyrie-engine-boundary.sh new file mode 100755 index 00000000..1ff9bccd --- /dev/null +++ b/scripts/check-eyrie-engine-boundary.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if command -v rg >/dev/null 2>&1; then + eyrie_imports="$( + rg -n '"github\.com/GrayCodeAI/eyrie/[^\"]+"' \ + --glob '*.go' --glob '!*_test.go' --glob '!external/**' . || true + )" +else + eyrie_imports="$( + grep -RInE --include='*.go' --exclude='*_test.go' --exclude-dir=external \ + '"github\.com/GrayCodeAI/eyrie/[^\"]+"' . || true + )" +fi +violations="$(printf '%s\n' "$eyrie_imports" | grep -vE '"github\.com/GrayCodeAI/eyrie/engine(/|\")' || true)" + +if [[ -n "$violations" ]]; then + echo "direct production imports below the eyrie/engine facade found:" + echo "$violations" + echo + echo "route every Hawk production integration through github.com/GrayCodeAI/eyrie/engine" + exit 1 +fi + +if command -v rg >/dev/null 2>&1; then + credential_symbols="$(rg -n '\b(apiKeys|SetAPIKey|SetAPIKeys)\b' internal/engine --glob '*.go' --glob '!*_test.go' || true)" +else + credential_symbols="$(grep -RInE --include='*.go' --exclude='*_test.go' '(^|[^[:alnum:]_])(apiKeys|SetAPIKey|SetAPIKeys)([^[:alnum:]_]|$)' internal/engine || true)" +fi +if [[ -n "$credential_symbols" ]]; then + printf '%s\n' "$credential_symbols" + echo + echo "provider credentials must not enter Hawk's agent/session layer" + exit 1 +fi + +echo "eyrie engine boundary passed (zero lower-level production imports)"