From ece225929ff9872cfc4799138a0ef31e8ecfc2ac Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:14:18 +0530 Subject: [PATCH 1/5] fix(types,tools): guard against negative-index panics in String() methods Both Severity.String() and FinalizeErrorCode.String() checked only the upper bound (int(x) < len(names)) without a lower-bound check, so a negative enum value would pass the guard and panic on array indexing. Add int(x) >= 0 check. --- tools/versioning.go | 2 +- types/severity.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/versioning.go b/tools/versioning.go index e367932..9bd3850 100644 --- a/tools/versioning.go +++ b/tools/versioning.go @@ -53,7 +53,7 @@ var finalizeErrorCodeNames = [...]string{ } func (c FinalizeErrorCode) String() string { - if int(c) < len(finalizeErrorCodeNames) { + if int(c) >= 0 && int(c) < len(finalizeErrorCodeNames) { return finalizeErrorCodeNames[c] } return "unknown" diff --git a/types/severity.go b/types/severity.go index 921bc82..62c9c46 100644 --- a/types/severity.go +++ b/types/severity.go @@ -16,7 +16,7 @@ const ( var severityNames = [...]string{"info", "low", "medium", "high", "critical"} func (s Severity) String() string { - if int(s) < len(severityNames) { + if int(s) >= 0 && int(s) < len(severityNames) { return severityNames[s] } return "unknown" From b68c3ec99ddf97930ab9806bacd099429ade97fa Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 12:45:38 +0530 Subject: [PATCH 2/5] feat(contracts): add tests for untested pure functions in types package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/severity_test.go: add TestSeverityString (all severity levels + out-of-range → "unknown") and TestSeverityAtLeast (ordering comparisons). - types/finding_test.go: add TestFindingSliceSortOrder (severity-desc then confidence-desc), TestFindingSliceFilterBySource, TestFindingSliceFilterByConfidence, TestFindingSliceByFile, TestFindingSliceSummary (total, by-source, by-severity, avg confidence), TestFindingSliceSummaryEmpty (zero-value guard), TestFindingFromSight, TestFindingFromInspect. All package tests pass (agent, events, llm, policy, review, sessions, tools, types, verify). Co-Authored-By: Grok --- types/finding_test.go | 126 +++++++++++++++++++++++++++++++++++++++++ types/severity_test.go | 28 +++++++++ 2 files changed, 154 insertions(+) diff --git a/types/finding_test.go b/types/finding_test.go index 6bf9572..26e8586 100644 --- a/types/finding_test.go +++ b/types/finding_test.go @@ -1,6 +1,7 @@ package types_test import ( + "sort" "testing" "github.com/GrayCodeAI/hawk-core-contracts/types" @@ -18,3 +19,128 @@ func TestFindingSliceFilterBySeverity(t *testing.T) { t.Fatalf("FilterBySeverity() len = %d, want 2", len(got)) } } + +func TestFindingSliceSortOrder(t *testing.T) { + findings := types.FindingSlice{ + {ID: "a", Severity: types.SeverityLow, Confidence: 0.9}, + {ID: "b", Severity: types.SeverityCritical, Confidence: 0.1}, + {ID: "c", Severity: types.SeverityHigh, Confidence: 0.5}, + } + sort.Sort(findings) + // Critical first, then high, then low. + if findings[0].ID != "b" || findings[1].ID != "c" || findings[2].ID != "a" { + t.Fatalf("sort order = %s,%s,%s, want b,c,a", findings[0].ID, findings[1].ID, findings[2].ID) + } +} + +func TestFindingSliceFilterBySource(t *testing.T) { + findings := types.FindingSlice{ + {ID: "a", Source: "sight"}, + {ID: "b", Source: "inspect"}, + {ID: "c", Source: "sight"}, + } + got := findings.FilterBySource("sight") + if len(got) != 2 { + t.Fatalf("FilterBySource(sight) len = %d, want 2", len(got)) + } + for _, f := range got { + if f.Source != "sight" { + t.Fatalf("FilterBySource returned non-sight finding %s", f.ID) + } + } +} + +func TestFindingSliceFilterByConfidence(t *testing.T) { + findings := types.FindingSlice{ + {ID: "a", Confidence: 0.3}, + {ID: "b", Confidence: 0.7}, + {ID: "c", Confidence: 0.95}, + } + got := findings.FilterByConfidence(0.7) + if len(got) != 2 { + t.Fatalf("FilterByConfidence(0.7) len = %d, want 2", len(got)) + } +} + +func TestFindingSliceByFile(t *testing.T) { + findings := types.FindingSlice{ + {ID: "a", File: "main.go"}, + {ID: "b", File: "util.go"}, + {ID: "c", File: "main.go"}, + } + byFile := findings.ByFile() + if len(byFile["main.go"]) != 2 { + t.Fatalf("ByFile[main.go] len = %d, want 2", len(byFile["main.go"])) + } + if len(byFile["util.go"]) != 1 { + t.Fatalf("ByFile[util.go] len = %d, want 1", len(byFile["util.go"])) + } +} + +func TestFindingSliceSummary(t *testing.T) { + findings := types.FindingSlice{ + {ID: "a", Source: "sight", Severity: types.SeverityHigh, Confidence: 0.8}, + {ID: "b", Source: "sight", Severity: types.SeverityLow, Confidence: 0.4}, + {ID: "c", Source: "inspect", Severity: types.SeverityCritical, Confidence: 0.9}, + } + summary := findings.Summary() + if summary.Total != 3 { + t.Fatalf("Summary.Total = %d, want 3", summary.Total) + } + if summary.BySource["sight"] != 2 { + t.Fatalf("Summary.BySource[sight] = %d, want 2", summary.BySource["sight"]) + } + if summary.BySource["inspect"] != 1 { + t.Fatalf("Summary.BySource[inspect] = %d, want 1", summary.BySource["inspect"]) + } + if summary.BySeverity["critical"] != 1 { + t.Fatalf("Summary.BySeverity[critical] = %d, want 1", summary.BySeverity["critical"]) + } + wantAvg := (0.8 + 0.4 + 0.9) / 3.0 + if summary.AvgConfidence < wantAvg-0.001 || summary.AvgConfidence > wantAvg+0.001 { + t.Fatalf("Summary.AvgConfidence = %f, want ~%f", summary.AvgConfidence, wantAvg) + } +} + +func TestFindingSliceSummaryEmpty(t *testing.T) { + var empty types.FindingSlice + summary := empty.Summary() + if summary.Total != 0 { + t.Fatalf("Summary.Total = %d, want 0", summary.Total) + } + if summary.AvgConfidence != 0 { + t.Fatalf("Summary.AvgConfidence = %f, want 0 for empty slice", summary.AvgConfidence) + } +} + +func TestFindingFromSight(t *testing.T) { + f := types.FindingFromSight("sql-injection", "db/query.go", 42, "unsanitized input", "CWE-89", types.SeverityCritical, 0.95) + if f.Source != "sight" { + t.Fatalf("Source = %q, want sight", f.Source) + } + if f.Concern != "sql-injection" || f.File != "db/query.go" || f.Line != 42 { + t.Fatalf("unexpected fields: %+v", f) + } + if f.CWE != "CWE-89" || f.Severity != types.SeverityCritical || f.Confidence != 0.95 { + t.Fatalf("unexpected fields: %+v", f) + } + if f.ID == "" { + t.Fatal("ID should not be empty") + } +} + +func TestFindingFromInspect(t *testing.T) { + f := types.FindingFromInspect("high-cpu", "loop-1", "inefficient loop", types.SeverityMedium, []string{"perf", "hotspot"}) + if f.Source != "inspect" { + t.Fatalf("Source = %q, want inspect", f.Source) + } + if f.Concern != "high-cpu" || f.URL != "loop-1" || f.Message != "inefficient loop" { + t.Fatalf("unexpected fields: %+v", f) + } + if len(f.Tags) != 2 || f.Tags[0] != "perf" || f.Tags[1] != "hotspot" { + t.Fatalf("unexpected tags: %v", f.Tags) + } + if f.ID == "" { + t.Fatal("ID should not be empty") + } +} diff --git a/types/severity_test.go b/types/severity_test.go index e532712..470d712 100644 --- a/types/severity_test.go +++ b/types/severity_test.go @@ -24,3 +24,31 @@ func TestParseSeverity(t *testing.T) { } } } + +func TestSeverityString(t *testing.T) { + cases := map[types.Severity]string{ + types.SeverityInfo: "info", + types.SeverityLow: "low", + types.SeverityMedium: "medium", + types.SeverityHigh: "high", + types.SeverityCritical: "critical", + 999: "unknown", + } + for sev, want := range cases { + if got := sev.String(); got != want { + t.Errorf("Severity(%d).String() = %q, want %q", int(sev), got, want) + } + } +} + +func TestSeverityAtLeast(t *testing.T) { + if !types.SeverityHigh.AtLeast(types.SeverityMedium) { + t.Error("High.AtLeast(Medium) = false, want true") + } + if !types.SeverityMedium.AtLeast(types.SeverityMedium) { + t.Error("Medium.AtLeast(Medium) = false, want true") + } + if types.SeverityLow.AtLeast(types.SeverityMedium) { + t.Error("Low.AtLeast(Medium) = true, want false") + } +} From b7c4c806f453c6fb05387b07678ea3854853071a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 12:31:23 +0530 Subject: [PATCH 3/5] feat: add graph package and proto - Add graph/ package with graph implementation - Add proto/hawk/contracts/v1/graph.proto - Update README --- README.md | 3 + graph/graph.go | 218 ++++++++++++++++++++++++++++ graph/graph_test.go | 62 ++++++++ proto/hawk/contracts/v1/graph.proto | 105 ++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 graph/graph.go create mode 100644 graph/graph_test.go create mode 100644 proto/hawk/contracts/v1/graph.proto diff --git a/README.md b/README.md index b44e1ae..69e98b4 100644 --- a/README.md +++ b/README.md @@ -26,6 +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 | +| `graph/` | `Node`, `Edge`, `Event`, `Provenance`, `Scope` | Portable, temporal graph vocabulary; each repo owns its own storage and projections | | `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,6 +41,7 @@ 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 +├── graph/ Node, Edge, Event, Provenance — portable graph contracts └── llm/ Provider + 7 roles, EventStreamer, conversation DTOs — hawk↔eyrie port ``` @@ -59,6 +61,7 @@ lower-level client transports. - finding/result models (severity, confidence, status) - engine request/response contracts - policy and tool contracts +- graph node, edge, event, scope, and provenance contracts ### Not allowed here diff --git a/graph/graph.go b/graph/graph.go new file mode 100644 index 0000000..4ef3f75 --- /dev/null +++ b/graph/graph.go @@ -0,0 +1,218 @@ +// Package graph defines the portable graph vocabulary shared across hawk-eco. +// +// The package contains data contracts only. Individual repositories retain +// ownership of their graph storage, projections, and runtime behavior. +package graph + +import ( + "fmt" + "strings" + "time" +) + +// NodeKind classifies a node by the ecosystem view to which it belongs. +type NodeKind string + +const ( + NodeSystem NodeKind = "system" + NodeKnowledge NodeKind = "knowledge" + NodeExecution NodeKind = "execution" + NodePolicy NodeKind = "policy" + NodeQuality NodeKind = "quality" + NodeOperations NodeKind = "operations" +) + +// ParseNodeKind normalizes a graph node kind. +func ParseNodeKind(s string) (NodeKind, error) { + switch NodeKind(strings.ToLower(strings.TrimSpace(s))) { + case NodeSystem, NodeKnowledge, NodeExecution, NodePolicy, NodeQuality, NodeOperations: + return NodeKind(strings.ToLower(strings.TrimSpace(s))), nil + default: + return "", fmt.Errorf("graph: unknown node kind %q", s) + } +} + +// EdgeKind identifies a portable relationship between two graph nodes. +type EdgeKind string + +const ( + EdgeContains EdgeKind = "contains" + EdgeDependsOn EdgeKind = "depends_on" + EdgeReferences EdgeKind = "references" + EdgeProduced EdgeKind = "produced" + EdgeGovernedBy EdgeKind = "governed_by" + EdgeValidatedBy EdgeKind = "validated_by" +) + +// ParseEdgeKind normalizes a graph edge kind. +func ParseEdgeKind(s string) (EdgeKind, error) { + switch EdgeKind(strings.ToLower(strings.TrimSpace(s))) { + case EdgeContains, EdgeDependsOn, EdgeReferences, EdgeProduced, EdgeGovernedBy, EdgeValidatedBy: + return EdgeKind(strings.ToLower(strings.TrimSpace(s))), nil + default: + return "", fmt.Errorf("graph: unknown edge kind %q", s) + } +} + +// EventType identifies a lifecycle event for a graph subject. +type EventType string + +const ( + EventCreated EventType = "created" + EventUpdated EventType = "updated" + EventTransitioned EventType = "transitioned" + EventObserved EventType = "observed" + EventDeleted EventType = "deleted" +) + +// ParseEventType normalizes a graph event type. +func ParseEventType(s string) (EventType, error) { + switch EventType(strings.ToLower(strings.TrimSpace(s))) { + case EventCreated, EventUpdated, EventTransitioned, EventObserved, EventDeleted: + return EventType(strings.ToLower(strings.TrimSpace(s))), nil + default: + return "", fmt.Errorf("graph: unknown event type %q", s) + } +} + +// Scope limits a graph fact to its authorized ecosystem boundary. Empty fields +// are valid for local-only or global facts. +type Scope struct { + TenantID string `json:"tenant_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` +} + +// Ref identifies a graph node without embedding its mutable attributes. +type Ref struct { + Kind NodeKind `json:"kind"` + ID string `json:"id"` +} + +// Validate reports whether r can safely identify a graph node. +func (r Ref) Validate() error { + if _, err := ParseNodeKind(string(r.Kind)); err != nil { + return err + } + if strings.TrimSpace(r.ID) == "" { + return fmt.Errorf("graph: reference ID is required") + } + return nil +} + +// ArtifactRef points to immutable evidence outside the contract payload. +type ArtifactRef struct { + URI string `json:"uri"` + Digest string `json:"digest,omitempty"` + MediaType string `json:"media_type,omitempty"` +} + +// Provenance identifies the producer and evidence for a graph fact. +type Provenance struct { + Producer string `json:"producer"` + Version string `json:"version,omitempty"` + SourceID string `json:"source_id,omitempty"` + Evidence []ArtifactRef `json:"evidence,omitempty"` +} + +// Validate reports whether p can support an auditable graph fact. +func (p Provenance) Validate() error { + if strings.TrimSpace(p.Producer) == "" { + return fmt.Errorf("graph: provenance producer is required") + } + for i, evidence := range p.Evidence { + if strings.TrimSpace(evidence.URI) == "" { + return fmt.Errorf("graph: evidence[%d] URI is required", i) + } + } + return nil +} + +// Node is a typed, temporal graph fact. Attributes are intentionally bounded +// to strings; large or sensitive data belongs in ArtifactRef evidence. +type Node struct { + ID string `json:"id"` + Kind NodeKind `json:"kind"` + Scope Scope `json:"scope,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt time.Time `json:"effective_at,omitempty"` + Provenance Provenance `json:"provenance"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// Validate reports whether n satisfies the minimum shared graph contract. +func (n Node) Validate() error { + if strings.TrimSpace(n.ID) == "" { + return fmt.Errorf("graph: node ID is required") + } + if _, err := ParseNodeKind(string(n.Kind)); err != nil { + return err + } + if n.CreatedAt.IsZero() { + return fmt.Errorf("graph: node created_at is required") + } + return n.Provenance.Validate() +} + +// Edge is a typed, temporal relationship between two graph nodes. +type Edge struct { + ID string `json:"id"` + Kind EdgeKind `json:"kind"` + From Ref `json:"from"` + To Ref `json:"to"` + Scope Scope `json:"scope,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt time.Time `json:"effective_at,omitempty"` + Provenance Provenance `json:"provenance"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// Validate reports whether e satisfies the minimum shared graph contract. +func (e Edge) Validate() error { + if strings.TrimSpace(e.ID) == "" { + return fmt.Errorf("graph: edge ID is required") + } + if _, err := ParseEdgeKind(string(e.Kind)); err != nil { + return err + } + if err := e.From.Validate(); err != nil { + return fmt.Errorf("graph: edge from: %w", err) + } + if err := e.To.Validate(); err != nil { + return fmt.Errorf("graph: edge to: %w", err) + } + if e.CreatedAt.IsZero() { + return fmt.Errorf("graph: edge created_at is required") + } + return e.Provenance.Validate() +} + +// Event records an immutable lifecycle observation for a graph subject. +type Event struct { + ID string `json:"id"` + Type EventType `json:"type"` + Subject Ref `json:"subject"` + Scope Scope `json:"scope,omitempty"` + OccurredAt time.Time `json:"occurred_at"` + CorrelationID string `json:"correlation_id,omitempty"` + CausationID string `json:"causation_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + Provenance Provenance `json:"provenance"` +} + +// Validate reports whether e satisfies the minimum shared graph event contract. +func (e Event) Validate() error { + if strings.TrimSpace(e.ID) == "" { + return fmt.Errorf("graph: event ID is required") + } + if _, err := ParseEventType(string(e.Type)); err != nil { + return err + } + if err := e.Subject.Validate(); err != nil { + return fmt.Errorf("graph: event subject: %w", err) + } + if e.OccurredAt.IsZero() { + return fmt.Errorf("graph: event occurred_at is required") + } + return e.Provenance.Validate() +} diff --git a/graph/graph_test.go b/graph/graph_test.go new file mode 100644 index 0000000..ec3fa3c --- /dev/null +++ b/graph/graph_test.go @@ -0,0 +1,62 @@ +package graph_test + +import ( + "testing" + "time" + + "github.com/GrayCodeAI/hawk-core-contracts/graph" +) + +func TestNodeValidate(t *testing.T) { + node := graph.Node{ + ID: "task:abc123", + Kind: graph.NodeExecution, + CreatedAt: time.Date(2026, time.July, 25, 0, 0, 0, 0, time.UTC), + Provenance: graph.Provenance{ + Producer: "hawk", + Evidence: []graph.ArtifactRef{{URI: "trace://session/abc123"}}, + }, + } + if err := node.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestEdgeValidateRejectsIncompleteReference(t *testing.T) { + edge := graph.Edge{ + ID: "edge:1", + Kind: graph.EdgeDependsOn, + From: graph.Ref{Kind: graph.NodeExecution, ID: "task:1"}, + To: graph.Ref{Kind: graph.NodeQuality}, + CreatedAt: time.Now(), + Provenance: graph.Provenance{Producer: "hawk"}, + } + if err := edge.Validate(); err == nil { + t.Fatal("Validate() error = nil, want error for missing target ID") + } +} + +func TestEventValidate(t *testing.T) { + event := graph.Event{ + ID: "event:1", + Type: graph.EventTransitioned, + Subject: graph.Ref{Kind: graph.NodeExecution, ID: "task:1"}, + OccurredAt: time.Now(), + Provenance: graph.Provenance{Producer: "hawk"}, + } + if err := event.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestParseRejectsUnknownKinds(t *testing.T) { + if _, err := graph.ParseNodeKind("unknown"); err == nil { + t.Fatal("ParseNodeKind() error = nil, want error") + } + if _, err := graph.ParseEdgeKind("unknown"); err == nil { + t.Fatal("ParseEdgeKind() error = nil, want error") + } + if _, err := graph.ParseEventType("unknown"); err == nil { + t.Fatal("ParseEventType() error = nil, want error") + } +} diff --git a/proto/hawk/contracts/v1/graph.proto b/proto/hawk/contracts/v1/graph.proto new file mode 100644 index 0000000..6ec1179 --- /dev/null +++ b/proto/hawk/contracts/v1/graph.proto @@ -0,0 +1,105 @@ +syntax = "proto3"; + +package hawk.contracts.v1; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/GrayCodeAI/hawk-core-contracts/gen/go/hawk/contracts/v1;contractsv1"; + +// NodeKind mirrors graph.NodeKind (graph/graph.go). +enum NodeKind { + NODE_KIND_UNSPECIFIED = 0; + NODE_KIND_SYSTEM = 1; + NODE_KIND_KNOWLEDGE = 2; + NODE_KIND_EXECUTION = 3; + NODE_KIND_POLICY = 4; + NODE_KIND_QUALITY = 5; + NODE_KIND_OPERATIONS = 6; +} + +// EdgeKind mirrors graph.EdgeKind (graph/graph.go). +enum EdgeKind { + EDGE_KIND_UNSPECIFIED = 0; + EDGE_KIND_CONTAINS = 1; + EDGE_KIND_DEPENDS_ON = 2; + EDGE_KIND_REFERENCES = 3; + EDGE_KIND_PRODUCED = 4; + EDGE_KIND_GOVERNED_BY = 5; + EDGE_KIND_VALIDATED_BY = 6; +} + +// GraphEventType mirrors graph.EventType (graph/graph.go). +enum GraphEventType { + GRAPH_EVENT_TYPE_UNSPECIFIED = 0; + GRAPH_EVENT_TYPE_CREATED = 1; + GRAPH_EVENT_TYPE_UPDATED = 2; + GRAPH_EVENT_TYPE_TRANSITIONED = 3; + GRAPH_EVENT_TYPE_OBSERVED = 4; + GRAPH_EVENT_TYPE_DELETED = 5; +} + +// Scope mirrors graph.Scope and limits a fact to its authorized boundary. +message GraphScope { + string tenant_id = 1; + string project_id = 2; + string repository_id = 3; +} + +// GraphRef mirrors graph.Ref. +message GraphRef { + NodeKind kind = 1; + string id = 2; +} + +// ArtifactRef mirrors graph.ArtifactRef. Large and sensitive data remains +// outside graph contracts and is represented by immutable references. +message ArtifactRef { + string uri = 1; + string digest = 2; + string media_type = 3; +} + +// Provenance mirrors graph.Provenance. +message Provenance { + string producer = 1; + string version = 2; + string source_id = 3; + repeated ArtifactRef evidence = 4; +} + +// GraphNode mirrors graph.Node. +message GraphNode { + string id = 1; + NodeKind kind = 2; + GraphScope scope = 3; + google.protobuf.Timestamp created_at = 4; + google.protobuf.Timestamp effective_at = 5; + Provenance provenance = 6; + map attributes = 7; +} + +// GraphEdge mirrors graph.Edge. +message GraphEdge { + string id = 1; + EdgeKind kind = 2; + GraphRef from = 3; + GraphRef to = 4; + GraphScope scope = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp effective_at = 7; + Provenance provenance = 8; + map attributes = 9; +} + +// GraphEvent mirrors graph.Event. +message GraphEvent { + string id = 1; + GraphEventType type = 2; + GraphRef subject = 3; + GraphScope scope = 4; + google.protobuf.Timestamp occurred_at = 5; + string correlation_id = 6; + string causation_id = 7; + string idempotency_key = 8; + Provenance provenance = 9; +} From 9c81433ffa13f78c7783f8505775cd1ba005a745 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:05:07 +0530 Subject: [PATCH 4/5] feat: enhance graph contracts with LangGraph patterns - Add NodeType enum (agent, tool, function, start, end, router) - Add NodeSpec, EdgeSpec, EdgeCondition, GraphSpec types - Update proto definitions to match - Add validation methods for new types --- graph/graph.go | 110 ++++++++++++++++++++++++++++ proto/hawk/contracts/v1/graph.proto | 44 +++++++++++ 2 files changed, 154 insertions(+) diff --git a/graph/graph.go b/graph/graph.go index 4ef3f75..c867b12 100644 --- a/graph/graph.go +++ b/graph/graph.go @@ -2,8 +2,31 @@ // // The package contains data contracts only. Individual repositories retain // ownership of their graph storage, projections, and runtime behavior. +// +// Graph Engineering Patterns: +// This package implements the foundational patterns from LangGraph, +// Microsoft AutoGen, and Google ADK for multi-agent orchestration as +// nodes, edges, and shared state. package graph +// NodeType classifies a node by its role in agent orchestration. +type NodeType string + +const ( + // NodeTypeAgent represents an autonomous agent that can make decisions + NodeTypeAgent NodeType = "agent" + // NodeTypeTool represents a tool/function that agents can invoke + NodeTypeTool NodeType = "tool" + // NodeTypeFunction represents a function node (deterministic operation) + NodeTypeFunction NodeType = "function" + // NodeTypeStart represents the entry point of a graph + NodeTypeStart NodeType = "start" + // NodeTypeEnd represents the termination point of a graph + NodeTypeEnd NodeType = "end" + // NodeTypeRouter represents a conditional routing node + NodeTypeRouter NodeType = "router" +) + import ( "fmt" "strings" @@ -32,6 +55,25 @@ func ParseNodeKind(s string) (NodeKind, error) { } } +// ParseNodeType normalizes a graph node type. +func ParseNodeType(s string) (NodeType, error) { + switch NodeType(strings.ToLower(strings.TrimSpace(s))) { + case NodeTypeAgent, NodeTypeTool, NodeTypeFunction, NodeTypeStart, NodeTypeEnd, NodeTypeRouter: + return NodeType(strings.ToLower(strings.TrimSpace(s))), nil + default: + return "", fmt.Errorf("graph: unknown node type %q", s) + } +} + +// NodeSpec describes a node in an orchestration graph. +type NodeSpec struct { + ID string `json:"id"` + Type NodeType `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Config map[string]string `json:"config,omitempty"` +} + // EdgeKind identifies a portable relationship between two graph nodes. type EdgeKind string @@ -44,6 +86,31 @@ const ( EdgeValidatedBy EdgeKind = "validated_by" ) +// EdgeCondition represents a conditional edge in orchestration graphs. +// When non-empty, the edge is only followed if the condition evaluates to true. +type EdgeCondition struct { + Expression string `json:"expression,omitempty"` + Variables map[string]string `json:"variables,omitempty"` +} + +// EdgeSpec describes an edge in an orchestration graph. +type EdgeSpec struct { + From string `json:"from"` + To string `json:"to"` + Condition *EdgeCondition `json:"condition,omitempty"` + Weight float64 `json:"weight,omitempty"` +} + +// GraphSpec describes a complete orchestration graph. +type GraphSpec struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Nodes []NodeSpec `json:"nodes"` + Edges []EdgeSpec `json:"edges"` + Metadata map[string]string `json:"metadata,omitempty"` +} + // ParseEdgeKind normalizes a graph edge kind. func ParseEdgeKind(s string) (EdgeKind, error) { switch EdgeKind(strings.ToLower(strings.TrimSpace(s))) { @@ -154,6 +221,49 @@ func (n Node) Validate() error { return n.Provenance.Validate() } +// Validate reports whether s satisfies the minimum node spec contract. +func (s NodeSpec) Validate() error { + if strings.TrimSpace(s.ID) == "" { + return fmt.Errorf("graph: node spec ID is required") + } + if _, err := ParseNodeType(s.Type); err != nil { + return err + } + return nil +} + +// Validate reports whether s satisfies the minimum edge spec contract. +func (s EdgeSpec) Validate() error { + if strings.TrimSpace(s.From) == "" { + return fmt.Errorf("graph: edge spec from is required") + } + if strings.TrimSpace(s.To) == "" { + return fmt.Errorf("graph: edge spec to is required") + } + return nil +} + +// Validate reports whether s satisfies the minimum graph spec contract. +func (s GraphSpec) Validate() error { + if strings.TrimSpace(s.ID) == "" { + return fmt.Errorf("graph: graph spec ID is required") + } + if len(s.Nodes) == 0 { + return fmt.Errorf("graph: graph spec must have at least one node") + } + for i, node := range s.Nodes { + if err := node.Validate(); err != nil { + return fmt.Errorf("graph: node[%d]: %w", i, err) + } + } + for i, edge := range s.Edges { + if err := edge.Validate(); err != nil { + return fmt.Errorf("graph: edge[%d]: %w", i, err) + } + } + return nil +} + // Edge is a typed, temporal relationship between two graph nodes. type Edge struct { ID string `json:"id"` diff --git a/proto/hawk/contracts/v1/graph.proto b/proto/hawk/contracts/v1/graph.proto index 6ec1179..b59a357 100644 --- a/proto/hawk/contracts/v1/graph.proto +++ b/proto/hawk/contracts/v1/graph.proto @@ -17,6 +17,17 @@ enum NodeKind { NODE_KIND_OPERATIONS = 6; } +// NodeType mirrors graph.NodeType (graph/graph.go). +enum NodeType { + NODE_TYPE_UNSPECIFIED = 0; + NODE_TYPE_AGENT = 1; + NODE_TYPE_TOOL = 2; + NODE_TYPE_FUNCTION = 3; + NODE_TYPE_START = 4; + NODE_TYPE_END = 5; + NODE_TYPE_ROUTER = 6; +} + // EdgeKind mirrors graph.EdgeKind (graph/graph.go). enum EdgeKind { EDGE_KIND_UNSPECIFIED = 0; @@ -103,3 +114,36 @@ message GraphEvent { string idempotency_key = 8; Provenance provenance = 9; } + +// NodeSpec mirrors graph.NodeSpec. +message NodeSpec { + string id = 1; + NodeType type = 2; + string name = 3; + string description = 4; + map config = 5; +} + +// EdgeCondition mirrors graph.EdgeCondition. +message EdgeCondition { + string expression = 1; + map variables = 2; +} + +// EdgeSpec mirrors graph.EdgeSpec. +message EdgeSpec { + string from = 1; + string to = 2; + EdgeCondition condition = 3; + double weight = 4; +} + +// GraphSpec mirrors graph.GraphSpec. +message GraphSpec { + string id = 1; + string name = 2; + string description = 3; + repeated NodeSpec nodes = 4; + repeated EdgeSpec edges = 5; + map metadata = 6; +} From e517d07f604929c7b020e67a3506ee876cd8ad2a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:15:03 +0530 Subject: [PATCH 5/5] feat: add prompt engineering template engine - Add robust Prompt Template engine with Go template support - Isolate prompt engineering logic into a core portable contract package --- prompt/prompt.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 prompt/prompt.go diff --git a/prompt/prompt.go b/prompt/prompt.go new file mode 100644 index 0000000..a558390 --- /dev/null +++ b/prompt/prompt.go @@ -0,0 +1,31 @@ +package prompt + +import ( + "bytes" + "fmt" + "text/template" +) + +// Template represents a compiled Prompt Engineering template. +type Template struct { + name string + tmpl *template.Template +} + +// NewTemplate creates a new prompt template. +func NewTemplate(name, text string) (*Template, error) { + tmpl, err := template.New(name).Parse(text) + if err != nil { + return nil, fmt.Errorf("failed to parse prompt template: %w", err) + } + return &Template{name: name, tmpl: tmpl}, nil +} + +// Render executes the prompt template with context variables. +func (t *Template) Render(vars map[string]interface{}) (string, error) { + var buf bytes.Buffer + if err := t.tmpl.Execute(&buf, vars); err != nil { + return "", fmt.Errorf("failed to render prompt template %q: %w", t.name, err) + } + return buf.String(), nil +}