From b68efbdc42b98f9526ae2f9b3c76bc7f44133f1f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 22 Jul 2026 22:23:47 +0530 Subject: [PATCH 1/3] fix(llm): align host port, unify ToolCall, bump VERSION to 0.1.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct VERSION (was stuck at 0.1.6 after the llm package release), alias ToolCall/ToolResult to tools/, align CatalogHealth/DeploymentSummary/ Preflight/ProviderStateSecurity with the eyrie facade, introduce pull-based EventStreamer on Generator.Stream, add llm.proto + unit tests, and document the hawk↔eyrie port. --- CHANGELOG.md | 13 ++ README.md | 17 ++- VERSION | 2 +- llm/provider.go | 77 ++++++++---- llm/types.go | 23 ++-- llm/types_test.go | 89 ++++++++++++++ proto/hawk/contracts/v1/llm.proto | 196 ++++++++++++++++++++++++++++++ 7 files changed, 372 insertions(+), 45 deletions(-) create mode 100644 llm/types_test.go create mode 100644 proto/hawk/contracts/v1/llm.proto diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c9bc5..afc4994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `VERSION` file now reports `0.1.7` (was left at `0.1.6` after the llm + package release; `version.go` embeds this file). +- `llm.ToolCall` / `llm.ToolResult` are type aliases of `tools.ToolCall` / + `tools.ToolResult` so the ecosystem has one tool-call vocabulary. +- Host-facing `llm` types aligned with the eyrie engine facade: + `CatalogHealth`, `DeploymentSummary`, `PreflightReport` (`LiveVerified`), + `ProviderStateSecurity`, and pull-based `EventStreamer` on `Generator.Stream`. +- `proto/hawk/contracts/v1/llm.proto` mirrors llm conversation / host DTOs; + `make proto` regenerates agent + llm stubs under `gen/`. +- Unit tests for `llm` (`StreamResult`, aliases, constants, `EventStreamer`). + ## [0.1.7] — 2026-07-22 ### Added diff --git a/README.md b/README.md index 0c45d36..b44e1ae 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ go get github.com/GrayCodeAI/hawk-core-contracts | `verify/` | `Report`, `Finding` | Neutral verification findings, stats, and report contracts | | `sessions/` | `Phase`, `CostAccumulator`, `ParsePhase` | Cross-repo agent session state types | | `agent/` | `SpawnRequest`, `SpawnResult`, hook event names | Typed subagent spawn + hook vocabulary | -| `llm/` | `Provider` + roles, `EyrieMessage`, `EyrieResponse`, `ChatOptions`, `StreamEvent`, `StreamResult`, `Model`, `Usage`, … | Canonical provider port contract — the hawk↔eyrie boundary | +| `llm/` | `Provider` + roles, `EventStreamer`, `EyrieMessage`, `EyrieResponse`, `ChatOptions`, `EyrieStreamEvent`, `StreamResult`, `Model`, `Usage`, … | Canonical provider port contract — the hawk↔eyrie boundary (`ToolCall`/`ToolResult` alias `tools/`) | ## Architecture @@ -40,9 +40,15 @@ hawk-core-contracts (stdlib only) ├── verify/ Report, Finding — verification report contracts ├── sessions/ Phase, CostAccumulator — session state contracts ├── agent/ SpawnRequest, SpawnResult, hook events — subagent spawn contracts -└── llm/ Provider + 7 roles, conversation DTOs — the hawk↔eyrie port contract +└── llm/ Provider + 7 roles, EventStreamer, conversation DTOs — hawk↔eyrie port ``` +`llm.ToolCall` / `llm.ToolResult` are type aliases of `tools.ToolCall` / +`tools.ToolResult` (single vocabulary). Host streaming on the port is +pull-based (`EventStreamer`); channel-based `StreamResult` remains for +lower-level client transports. + + ## Scope ### Allowed here @@ -64,8 +70,9 @@ hawk-core-contracts (stdlib only) - anything that belongs in a single consuming repo Engines should depend on this repo only when they produce or consume a shared -cross-repo contract. Contract-free engines (e.g., `eyrie`, `yaad`, `trace`) -should not add the dependency just for consistency. +cross-repo contract. Eyrie depends on `llm/` for the host port; other engines +(e.g. `yaad`, `trace`) stay contract-free unless they share a real cross-repo +type. If a type is only used inside one repo, it should stay in that repo. @@ -186,6 +193,8 @@ numeric values identical to the Go `iota` constants that | `/review/` | `@GrayCodeAI/llm-team` | | `/verify/` | `@GrayCodeAI/llm-team` | | `/sessions/` | `@GrayCodeAI/llm-team` | +| `/agent/` | `@GrayCodeAI/llm-team` | +| `/llm/` | `@GrayCodeAI/llm-team` | | `/proto/` | `@GrayCodeAI/llm-team` | | `/VERSION` | `@GrayCodeAI/maintainers` | | `/Makefile` | `@GrayCodeAI/devops-team` | diff --git a/VERSION b/VERSION index c946ee6..1180819 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.6 +0.1.7 diff --git a/llm/provider.go b/llm/provider.go index 5218225..1031ad8 100644 --- a/llm/provider.go +++ b/llm/provider.go @@ -24,9 +24,22 @@ type Provider interface { } // Generator is the chat transport facet: the only part the ChatClient path uses. +// +// Stream returns a pull-based EventStreamer (the host engine facade contract). +// Lower-level channel-based streaming still uses StreamResult on the client +// transport layer; that type is intentionally not part of this host port. type Generator interface { Generate(ctx context.Context, req GenerateRequest) (*EyrieResponse, error) - Stream(ctx context.Context, req GenerateRequest) (*StreamResult, error) + Stream(ctx context.Context, req GenerateRequest) (EventStreamer, error) +} + +// EventStreamer is the pull-based host stream contract used by the engine facade. +// Next must not be called concurrently. Close is idempotent. +type EventStreamer interface { + Next() bool + Event() EyrieStreamEvent + Err() error + Close() error } // GenerateRequest is the normalized generation request. @@ -281,12 +294,11 @@ type Gateway struct { Active bool `json:"active"` } -// DeploymentSummary summarizes deployment routing for a model. +// DeploymentSummary summarizes deployment routing for a model (host-facing). type DeploymentSummary struct { - // mirrors eyrieengine.DeploymentSummary - ActiveModel string `json:"active_model"` - Status string `json:"status"` - Router string `json:"router,omitempty"` + RoutingSource string `json:"routing_source,omitempty"` + RoutingStages int `json:"routing_stages,omitempty"` + Formatted string `json:"formatted"` } // CatalogMaintenance is the refresh/preflight/security facet (config only). @@ -302,12 +314,19 @@ type CatalogMaintenance interface { MigrateProviderSecretsContext(ctx context.Context) error } -// CatalogHealth reports the health of the model catalog. +// CatalogHealth reports the health of the model catalog (host-facing). type CatalogHealth struct { - Healthy bool `json:"healthy"` - Path string `json:"path,omitempty"` - Models int `json:"models,omitempty"` - Error string `json:"error,omitempty"` + Path string `json:"path"` + Exists bool `json:"exists"` + ModifiedAt time.Time `json:"modified_at,omitempty"` + Size int64 `json:"size,omitempty"` + Models int `json:"models,omitempty"` + Deployments int `json:"deployments,omitempty"` + Offerings int `json:"offerings,omitempty"` + Stale bool `json:"stale,omitempty"` + StaleAfter time.Time `json:"stale_after,omitempty"` + Source string `json:"source,omitempty"` + Error string `json:"error,omitempty"` } // StatePaths is the on-disk location of catalog + provider config. @@ -318,35 +337,39 @@ type StatePaths struct { // PreflightOptions configures a preflight check. type PreflightOptions struct { - VerifyLive bool + VerifyLive bool `json:"verify_live,omitempty"` } // PreflightReport is the result of a preflight check. type PreflightReport struct { - Ready bool `json:"ready"` - Checks []PreflightCheck `json:"checks"` + Ready bool `json:"ready"` + LiveVerified bool `json:"live_verified"` + Checks []PreflightCheck `json:"checks"` } -// PreflightCheck is one readiness check. -type PreflightCheck struct { - Name string `json:"name"` - Status string `json:"status"` - Detail string `json:"detail,omitempty"` -} +// CheckStatus is a preflight check status string. +type CheckStatus string // Common preflight check statuses. const ( - CheckOK = "ok" - CheckFail = "fail" - CheckWarn = "warn" + CheckOK CheckStatus = "ok" + CheckFail CheckStatus = "fail" + CheckWarn CheckStatus = "warn" ) +// PreflightCheck is one readiness check. +type PreflightCheck struct { + Name string `json:"name"` + Status CheckStatus `json:"status"` + Detail string `json:"detail,omitempty"` +} + // ProviderStateSecurity reports security state of provider config. type ProviderStateSecurity struct { - PlatformStore string `json:"platform_store,omitempty"` - Sanitized bool `json:"sanitized"` - Detail string `json:"detail,omitempty"` - Error string `json:"error,omitempty"` + Path string `json:"path"` + HasSecrets bool `json:"has_secrets"` + Detail string `json:"detail,omitempty"` + Error string `json:"error,omitempty"` } // NativeCompactor is the provider-native-compaction facet. diff --git a/llm/types.go b/llm/types.go index 85d8e60..cac9387 100644 --- a/llm/types.go +++ b/llm/types.go @@ -10,7 +10,11 @@ // never appear here. package llm -import "context" +import ( + "context" + + "github.com/GrayCodeAI/hawk-core-contracts/tools" +) // EyrieConfig holds client configuration. type EyrieConfig struct { @@ -52,19 +56,12 @@ type EyrieMessage struct { ToolResults []ToolResult `json:"tool_results,omitempty"` } -// ToolCall is a tool invocation. -type ToolCall struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` -} +// ToolCall is a tool invocation. Aliased to tools.ToolCall so the ecosystem +// has a single ToolCall/ToolResult vocabulary (see tools/tool.go). +type ToolCall = tools.ToolCall -// ToolResult is a tool execution result. -type ToolResult struct { - ToolUseID string `json:"tool_use_id"` - Content string `json:"content"` - IsError bool `json:"is_error,omitempty"` -} +// ToolResult is a tool execution result. Aliased to tools.ToolResult. +type ToolResult = tools.ToolResult // EyrieTool is a tool definition. type EyrieTool struct { diff --git a/llm/types_test.go b/llm/types_test.go new file mode 100644 index 0000000..a1f19e0 --- /dev/null +++ b/llm/types_test.go @@ -0,0 +1,89 @@ +package llm + +import ( + "context" + "testing" + + "github.com/GrayCodeAI/hawk-core-contracts/tools" +) + +func TestToolCallAliasesToolsPackage(t *testing.T) { + // Ensure llm.ToolCall and tools.ToolCall are the same type (not merely similar). + var call ToolCall = tools.ToolCall{ + ID: "t1", + Name: "read", + Arguments: map[string]interface{}{ + "path": "main.go", + }, + } + if call.ID != "t1" || call.Name != "read" { + t.Fatalf("ToolCall alias lost fields: %+v", call) + } + var result ToolResult = tools.ToolResult{ + ToolUseID: "t1", + Content: "ok", + IsError: false, + } + if result.ToolUseID != "t1" || result.Content != "ok" { + t.Fatalf("ToolResult alias lost fields: %+v", result) + } +} + +func TestNewStreamResultAndClose(t *testing.T) { + events := make(chan EyrieStreamEvent, 1) + events <- EyrieStreamEvent{Type: "content", Content: "hi"} + close(events) + + cancelled := false + sr := NewStreamResult(events, "req-1", func() { cancelled = true }) + if sr.RequestID != "req-1" { + t.Fatalf("RequestID = %q, want req-1", sr.RequestID) + } + got := <-sr.Events + if got.Content != "hi" { + t.Fatalf("event content = %q, want hi", got.Content) + } + sr.Close() + if !cancelled { + t.Fatal("Close did not invoke cancel") + } + // Close is safe when cancel is nil. + NewStreamResult(nil, "", nil).Close() +} + +func TestStreamResultCloseNilReceiver(t *testing.T) { + var sr *StreamResult + sr.Close() // must not panic +} + +func TestIntentAndModelClassConstants(t *testing.T) { + if IntentFast == "" || IntentBalanced == "" || IntentReasoning == "" || IntentEconomical == "" { + t.Fatal("Intent constants must be non-empty") + } + if ModelClassEconomical == "" || ModelClassBalanced == "" || ModelClassPremium == "" { + t.Fatal("ModelClass constants must be non-empty") + } + if CheckOK != "ok" || CheckFail != "fail" || CheckWarn != "warn" { + t.Fatalf("check statuses: ok=%q fail=%q warn=%q", CheckOK, CheckFail, CheckWarn) + } +} + +func TestEventStreamerInterfaceShape(t *testing.T) { + // Compile-time: a stub that matches EventStreamer keeps the host port stable. + var _ EventStreamer = stubStreamer{} +} + +type stubStreamer struct{} + +func (stubStreamer) Next() bool { return false } +func (stubStreamer) Event() EyrieStreamEvent { return EyrieStreamEvent{} } +func (stubStreamer) Err() error { return nil } +func (stubStreamer) Close() error { return nil } + +func TestGenerateRequestZeroValue(t *testing.T) { + var req GenerateRequest + if req.Messages != nil || req.SystemPrompt != "" { + t.Fatalf("unexpected zero GenerateRequest: %+v", req) + } + _ = context.Background() +} diff --git a/proto/hawk/contracts/v1/llm.proto b/proto/hawk/contracts/v1/llm.proto new file mode 100644 index 0000000..52c8bb5 --- /dev/null +++ b/proto/hawk/contracts/v1/llm.proto @@ -0,0 +1,196 @@ +syntax = "proto3"; + +package hawk.contracts.v1; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; +import "hawk/contracts/v1/tool.proto"; + +option go_package = "github.com/GrayCodeAI/hawk-core-contracts/gen/go/hawk/contracts/v1;contractsv1"; + +// Conversation and host-port DTOs for the hawk↔eyrie boundary. +// Hand-written Go source of truth: llm/types.go and llm/provider.go. +// ToolCall/ToolResult intentionally reuse the canonical messages in tool.proto +// (llm.ToolCall is a Go type alias of tools.ToolCall). + +// ContentPart mirrors llm.ContentPart — a provider-neutral multimodal part. +message ContentPart { + string type = 1; + string text = 2; + ImageURLPart image_url = 3; + InputAudioPart input_audio = 4; +} + +// ImageURLPart mirrors llm.ImageURLPart. +message ImageURLPart { + string url = 1; + string detail = 2; +} + +// InputAudioPart mirrors llm.InputAudioPart. +message InputAudioPart { + string data = 1; + string format = 2; +} + +// EyrieMessage mirrors llm.EyrieMessage — the conversation message shape. +// tool_use / tool_results reuse ToolCall / ToolResult from tool.proto. +message EyrieMessage { + string role = 1; + string content = 2; + string thinking = 3; + repeated ContentPart content_parts = 4; + repeated string images = 5; + repeated ToolCall tool_use = 6; + repeated ToolResult tool_results = 7; +} + +// EyrieTool mirrors llm.EyrieTool — a model-visible tool definition. +message EyrieTool { + string name = 1; + string description = 2; + google.protobuf.Struct parameters = 3; +} + +// ResponseFormat mirrors llm.ResponseFormat. +message ResponseFormat { + string type = 1; + string schema = 2; +} + +// ToolChoiceOption mirrors llm.ToolChoiceOption. +message ToolChoiceOption { + string type = 1; + string name = 2; + bool disable_parallel_tool_use = 3; +} + +// EyrieUsage mirrors llm.EyrieUsage — normalized token accounting. +message EyrieUsage { + int32 prompt_tokens = 1; + int32 completion_tokens = 2; + int32 total_tokens = 3; + int32 cache_creation_tokens = 4; + int32 cache_read_tokens = 5; + int32 thinking_tokens = 6; +} + +// ResolvedRoute mirrors llm.ResolvedRoute — concrete provider/model route. +message ResolvedRoute { + string provider = 1; + string model = 2; + bool deployment_routing = 3; +} + +// EyrieResponse mirrors llm.EyrieResponse — blocking chat response DTO. +message EyrieResponse { + string content = 1; + string thinking = 2; + EyrieUsage usage = 3; + repeated ToolCall tool_calls = 4; + string finish_reason = 5; + string request_id = 6; + string organization_id = 7; + ResolvedRoute route = 8; +} + +// EyrieStreamEvent mirrors llm.EyrieStreamEvent — one stream event. +// TTFT and TTFTms both carry milliseconds; producers may set either field +// (see Go comments on llm.EyrieStreamEvent). +message EyrieStreamEvent { + string type = 1; + string content = 2; + ToolCall tool_call = 3; + string thinking = 4; + string error = 5; + string warning = 6; + string request_id = 7; + EyrieUsage usage = 8; + string stop_reason = 9; + int32 ttft_ms = 10; + int32 ttft = 11; + ResolvedRoute route = 12; +} + +// Model mirrors llm.Model — product-facing catalog row. +message Model { + string id = 1; + string provider_id = 2; + string canonical_id = 3; + string display_name = 4; + string description = 5; + string owner = 6; + string gateway_id = 7; + int32 context_window = 8; + int32 max_output_tokens = 9; + double input_price_per_1m = 10; + double output_price_per_1m = 11; + bool price_known = 12; + repeated string capabilities = 13; + string source = 14; + bytes live_metadata = 15; +} + +// CatalogSnapshot mirrors llm.CatalogSnapshot. +message CatalogSnapshot { + repeated Model models = 1; + string cache_path = 2; + string remote_url = 3; + bool stale = 4; + google.protobuf.Timestamp loaded_at = 5; +} + +// CredentialStatus mirrors llm.CredentialStatus (never contains secrets). +message CredentialStatus { + bool configured = 1; + string provider_id = 2; + string environment_variable = 3; + bool environment_conflict = 4; + bool verified = 5; + string masked = 6; + string env_var = 7; +} + +// CatalogHealth mirrors llm.CatalogHealth. +message CatalogHealth { + string path = 1; + bool exists = 2; + google.protobuf.Timestamp modified_at = 3; + int64 size = 4; + int32 models = 5; + int32 deployments = 6; + int32 offerings = 7; + bool stale = 8; + google.protobuf.Timestamp stale_after = 9; + string source = 10; + string error = 11; +} + +// DeploymentSummary mirrors llm.DeploymentSummary. +message DeploymentSummary { + string routing_source = 1; + int32 routing_stages = 2; + string formatted = 3; +} + +// PreflightReport mirrors llm.PreflightReport. +message PreflightReport { + bool ready = 1; + bool live_verified = 2; + repeated PreflightCheck checks = 3; +} + +// PreflightCheck mirrors llm.PreflightCheck. +message PreflightCheck { + string name = 1; + string status = 2; + string detail = 3; +} + +// ProviderStateSecurity mirrors llm.ProviderStateSecurity. +message ProviderStateSecurity { + string path = 1; + bool has_secrets = 2; + string detail = 3; + string error = 4; +} From 1ac244a8fd1b3ddbd25e131d40e617e7d27b960b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 22 Jul 2026 22:47:50 +0530 Subject: [PATCH 2/3] fix(llm): satisfy staticcheck ST1023 in alias tests Omit redundant typed var declarations so golangci-lint/staticcheck passes. --- llm/types_test.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/llm/types_test.go b/llm/types_test.go index a1f19e0..7978aa5 100644 --- a/llm/types_test.go +++ b/llm/types_test.go @@ -9,23 +9,25 @@ import ( func TestToolCallAliasesToolsPackage(t *testing.T) { // Ensure llm.ToolCall and tools.ToolCall are the same type (not merely similar). - var call ToolCall = tools.ToolCall{ + call := tools.ToolCall{ ID: "t1", Name: "read", Arguments: map[string]interface{}{ "path": "main.go", }, } - if call.ID != "t1" || call.Name != "read" { - t.Fatalf("ToolCall alias lost fields: %+v", call) + var asLLM ToolCall = call + if asLLM.ID != "t1" || asLLM.Name != "read" { + t.Fatalf("ToolCall alias lost fields: %+v", asLLM) } - var result ToolResult = tools.ToolResult{ + result := tools.ToolResult{ ToolUseID: "t1", Content: "ok", IsError: false, } - if result.ToolUseID != "t1" || result.Content != "ok" { - t.Fatalf("ToolResult alias lost fields: %+v", result) + var asLLMResult ToolResult = result + if asLLMResult.ToolUseID != "t1" || asLLMResult.Content != "ok" { + t.Fatalf("ToolResult alias lost fields: %+v", asLLMResult) } } From ca12a97c88e56c109c3ce8750844921ce78a4b31 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 22 Jul 2026 22:52:07 +0530 Subject: [PATCH 3/3] fix(llm): avoid ST1023 in ToolCall alias tests Use helper parameters for type identity checks so staticcheck does not flag redundant typed var declarations. --- llm/types_test.go | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/llm/types_test.go b/llm/types_test.go index 7978aa5..20bc032 100644 --- a/llm/types_test.go +++ b/llm/types_test.go @@ -8,26 +8,33 @@ import ( ) func TestToolCallAliasesToolsPackage(t *testing.T) { - // Ensure llm.ToolCall and tools.ToolCall are the same type (not merely similar). - call := tools.ToolCall{ + // Ensure llm.ToolCall and tools.ToolCall are the same type (not merely similar): + // tools.ToolCall must be assignable to llm.ToolCall without conversion. + assertLLMToolCall(t, tools.ToolCall{ ID: "t1", Name: "read", Arguments: map[string]interface{}{ "path": "main.go", }, - } - var asLLM ToolCall = call - if asLLM.ID != "t1" || asLLM.Name != "read" { - t.Fatalf("ToolCall alias lost fields: %+v", asLLM) - } - result := tools.ToolResult{ + }) + assertLLMToolResult(t, tools.ToolResult{ ToolUseID: "t1", Content: "ok", IsError: false, + }) +} + +func assertLLMToolCall(t *testing.T, call ToolCall) { + t.Helper() + if call.ID != "t1" || call.Name != "read" { + t.Fatalf("ToolCall alias lost fields: %+v", call) } - var asLLMResult ToolResult = result - if asLLMResult.ToolUseID != "t1" || asLLMResult.Content != "ok" { - t.Fatalf("ToolResult alias lost fields: %+v", asLLMResult) +} + +func assertLLMToolResult(t *testing.T, result ToolResult) { + t.Helper() + if result.ToolUseID != "t1" || result.Content != "ok" { + t.Fatalf("ToolResult alias lost fields: %+v", result) } }