From a9a4d2a17c6db1c9fdbcfa16fe3ddb9496c954a5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:14:18 +0530 Subject: [PATCH 01/15] docs: fix RetryableStatusCodes -> RetryableStatuses in README The README documented the retry config field as RetryableStatusCodes but the actual field in retry.go is RetryableStatuses. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d3dcd0d..2ba67bf 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ type RetryConfig struct { InitialBackoff time.Duration MaxBackoff time.Duration BackoffMultiplier float64 - RetryableStatusCodes []int + RetryableStatuses []int } ``` From 002dc1e2738589d622d499c1bc654a526832309b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:42:09 +0530 Subject: [PATCH 02/15] feat(stream): add NextContext for cancellation-aware stream reading Add StreamReader.NextContext(ctx) that closes the underlying response body on context cancellation, unblocking the scanner on stalled streams. The existing Next() method is unchanged for backward compatibility. --- stream.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/stream.go b/stream.go index 75b3c5e..74ed609 100644 --- a/stream.go +++ b/stream.go @@ -2,6 +2,7 @@ package hawksdk import ( "bufio" + "context" "fmt" "io" "net/http" @@ -80,3 +81,23 @@ func (sr *StreamReader) Close() error { } return nil } + +// NextContext reads the next SSE event, returning early if ctx is cancelled. +// Use this instead of Next when the caller needs cancellation support on a +// potentially stalled stream. Returns ctx.Err() if the context is done. +func (sr *StreamReader) NextContext(ctx context.Context) (*StreamEvent, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // Close the response body on cancellation to unblock the scanner. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + sr.Close() + case <-done: + } + }() + return sr.Next() +} From 52b386a96059949fc4e925c742b4638f45b0120d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:04:22 +0530 Subject: [PATCH 03/15] feat(agent): wire SystemPrompt, Temperature, TopP into ChatRequest Add SystemPrompt, Temperature, and TopP fields to AgentConfig and ChatRequest, and wire them through buildRequest. These are sent with omitempty so they're backward-compatible with daemons that don't yet support them. --- agent.go | 22 +++++++++++++++------- types.go | 17 ++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/agent.go b/agent.go index e7deae4..869a986 100644 --- a/agent.go +++ b/agent.go @@ -10,6 +10,15 @@ type AgentConfig struct { // Model specifies which LLM model to use. Model string + // SystemPrompt sets a custom system prompt for the agent. + SystemPrompt string + + // Temperature controls response randomness (0.0 = deterministic). + Temperature float64 + + // TopP controls nucleus sampling (0.0-1.0). + TopP float64 + // Tools are the tools available to this agent. Tools []Tool @@ -18,10 +27,6 @@ type AgentConfig struct { // Memory is an optional configuration for agent memory/context management. Memory *MemoryConfig - - // NOTE: Name, SystemPrompt, Temperature, and TopP are not yet supported - // by the daemon's ChatRequest API. They will be added when the server - // exposes corresponding fields. } // MemoryConfig configures memory behavior for an agent. @@ -129,9 +134,12 @@ func (a *Agent) SessionID() string { // buildRequest constructs a ChatRequest from the agent config and message. func (a *Agent) buildRequest(message string) ChatRequest { req := ChatRequest{ - Prompt: message, - Model: a.config.Model, - SessionID: a.sessionID, + Prompt: message, + Model: a.config.Model, + SessionID: a.sessionID, + SystemPrompt: a.config.SystemPrompt, + Temperature: a.config.Temperature, + TopP: a.config.TopP, } if a.config.MaxRounds > 0 { diff --git a/types.go b/types.go index 1ecd2d9..c250221 100644 --- a/types.go +++ b/types.go @@ -4,13 +4,16 @@ import "time" // ChatRequest is the request body for POST /v1/chat. type ChatRequest struct { - Prompt string `json:"prompt"` - SessionID string `json:"session_id,omitempty"` - Model string `json:"model,omitempty"` - MaxTurns int `json:"max_turns,omitempty"` - Autonomy string `json:"autonomy,omitempty"` - CWD string `json:"cwd,omitempty"` - Agent string `json:"agent,omitempty"` + Prompt string `json:"prompt"` + SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` + MaxTurns int `json:"max_turns,omitempty"` + Autonomy string `json:"autonomy,omitempty"` + CWD string `json:"cwd,omitempty"` + Agent string `json:"agent,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` } // ChatResponse is the response from POST /v1/chat. From 5358d00fe6d7e82b5baed6fae75b6aa565d107a7 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:32:57 +0530 Subject: [PATCH 04/15] feat(sdk): add OpenAPI tool-use fields to ChatRequest spec Add ToolChoice and ParallelToolCalls fields to ChatRequest, and wire ToolChoice through AgentConfig and buildRequest. Mirrors the OpenAPI tool-use spec (tool_choice, parallel_tool_calls) so callers can control tool selection behavior end-to-end. --- agent.go | 16 ++++++++++------ types.go | 22 ++++++++++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/agent.go b/agent.go index 869a986..f031c26 100644 --- a/agent.go +++ b/agent.go @@ -19,6 +19,9 @@ type AgentConfig struct { // TopP controls nucleus sampling (0.0-1.0). TopP float64 + // ToolChoice controls tool selection behavior: "none", "auto", "required". + ToolChoice string + // Tools are the tools available to this agent. Tools []Tool @@ -134,12 +137,13 @@ func (a *Agent) SessionID() string { // buildRequest constructs a ChatRequest from the agent config and message. func (a *Agent) buildRequest(message string) ChatRequest { req := ChatRequest{ - Prompt: message, - Model: a.config.Model, - SessionID: a.sessionID, - SystemPrompt: a.config.SystemPrompt, - Temperature: a.config.Temperature, - TopP: a.config.TopP, + Prompt: message, + Model: a.config.Model, + SessionID: a.sessionID, + SystemPrompt: a.config.SystemPrompt, + Temperature: a.config.Temperature, + TopP: a.config.TopP, + ToolChoice: a.config.ToolChoice, } if a.config.MaxRounds > 0 { diff --git a/types.go b/types.go index c250221..e808e22 100644 --- a/types.go +++ b/types.go @@ -4,16 +4,18 @@ import "time" // ChatRequest is the request body for POST /v1/chat. type ChatRequest struct { - Prompt string `json:"prompt"` - SessionID string `json:"session_id,omitempty"` - Model string `json:"model,omitempty"` - MaxTurns int `json:"max_turns,omitempty"` - Autonomy string `json:"autonomy,omitempty"` - CWD string `json:"cwd,omitempty"` - Agent string `json:"agent,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - TopP float64 `json:"top_p,omitempty"` + Prompt string `json:"prompt"` + SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` + MaxTurns int `json:"max_turns,omitempty"` + Autonomy string `json:"autonomy,omitempty"` + CWD string `json:"cwd,omitempty"` + Agent string `json:"agent,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"` } // ChatResponse is the response from POST /v1/chat. From 4b66b8c217aa8c2d21d443794b93c03225f6ebdb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:49:40 +0530 Subject: [PATCH 05/15] feat(spec): add tool_choice, parallel_tool_calls, and missing ChatRequest fields to OpenAPI spec The OpenAPI spec was missing system_prompt, temperature, top_p, tools, tool_results, tool_choice, and parallel_tool_calls from ChatRequest. Also adds the ToolResult schema that was referenced but not defined. --- api/openapi.yaml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index 4356670..4be32ec 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -61,6 +61,39 @@ components: agent: type: string description: Named Hawk agent persona to apply. Continuations inherit the persisted persona when omitted. + system_prompt: + type: string + description: Override the agent's system prompt for this request + temperature: + type: number + format: float + minimum: 0 + maximum: 2 + description: Controls response randomness (0.0 = deterministic) + top_p: + type: number + format: float + minimum: 0 + maximum: 1 + description: Nucleus sampling threshold (0.0-1.0) + tools: + type: array + items: + type: object + additionalProperties: true + description: Tool definitions available to the model for this request + tool_results: + type: array + items: + $ref: "#/components/schemas/ToolResult" + description: Results from previous tool calls + tool_choice: + type: string + enum: [none, auto, required] + description: Controls tool selection behavior + parallel_tool_calls: + type: boolean + description: Allow multiple tools to be called in a single response ChatResponse: type: object @@ -79,6 +112,21 @@ components: type: string description: Wall-clock duration, e.g. "1.234s" + ToolResult: + type: object + description: Result of executing a tool call. + required: [tool_use_id, content] + properties: + tool_use_id: + type: string + description: ID of the tool use this result corresponds to + content: + type: string + description: String result from the tool execution + is_error: + type: boolean + description: Whether the tool execution failed + Session: type: object description: Lightweight active record for a durably persisted daemon chat session. From 1c242c40e85e4442dc7eb9b6512f972d1d7d11b0 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 03:54:34 +0530 Subject: [PATCH 06/15] docs: list hawk-sdk-typescript in the ecosystem table The TypeScript SDK now completes the SDK trio alongside hawk-sdk-go and hawk-sdk-python; reflect it in the ecosystem component table. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2ba67bf..40d3208 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Hawk SDK for Go is part of the [hawk-eco](https://github.com/GrayCodeAI/hawk-eco | **hawk** | AI-powered coding agent for the terminal | | **hawk-sdk-go** | Go SDK for the Hawk daemon API | | **hawk-sdk-python** | Python SDK for the Hawk daemon API | +| **hawk-sdk-typescript** | TypeScript SDK for the Hawk daemon API | | **hawk-core-contracts** | Shared cross-repo contracts (types, events, tools) | ## Architecture From 09de3573392bd4b5647cf2554fc9ab647e6f1413 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 04:51:28 +0530 Subject: [PATCH 07/15] docs: document ChatWithTools, Tool types, and Agent/Workflow in README The README's API reference was missing the ChatWithTools method, Tool/ToolSchema/ToolCall/ToolResult types, ChatWithToolsRequest/Response types, Agent, Workflow, and StreamReader types. Added ChatWithTools to the Client Methods table, expanded the API Types table, and added a tool-use example to the Quick Start section. --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 40d3208..d47e305 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,28 @@ for { } fmt.Print(event.Data) } + +// Chat with tools — run the tool execution loop +tools := []hawksdk.Tool{ + { + Schema: hawksdk.ToolSchema{ + Name: "get_weather", + Description: "Get the current weather for a city", + Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`), + }, + Run: func(ctx context.Context, args map[string]any) (string, error) { + city := args["city"].(string) + return fmt.Sprintf("Sunny in %s", city), nil + }, + }, +} +resp, err = client.ChatWithTools(ctx, hawksdk.ChatRequest{ + Prompt: "What's the weather in San Francisco?", +}, tools, 10) +if err != nil { + log.Fatalf("chat with tools failed: %v", err) +} +fmt.Printf("Response: %s\n", resp.Response) ``` ## API Reference @@ -133,6 +155,7 @@ hawksdk.New(opts ...ClientOption) *Client | `Health(ctx) (*HealthResponse, error)` | Check daemon health and version | | `Chat(ctx, req ChatRequest) (*ChatResponse, error)` | Send a chat message | | `ChatStream(ctx, req ChatRequest) (*StreamReader, error)` | Stream chat responses via SSE | +| `ChatWithTools(ctx, req ChatRequest, tools []Tool, maxRounds int) (*ChatResponse, error)` | Run the tool execution loop | | `Sessions(ctx) ([]SessionSummary, error)` | List active sessions | | `Session(ctx, id string) (*SessionDetail, error)` | Get session details | | `Messages(ctx, sessionID, opts *ListOptions) (*PaginatedResponse[Message], error)` | Get paginated messages | @@ -145,11 +168,20 @@ hawksdk.New(opts ...ClientOption) *Client |------|-------------| | `ChatRequest` | Request body for `/v1/chat` | | `ChatResponse` | Response from `/v1/chat` | +| `ChatWithToolsRequest` | Request with tool definitions for `/v1/chat` | +| `ChatWithToolsResponse` | Response with tool call information | | `HealthResponse` | Response from `/v1/health` | | `SessionSummary` | Session listing entry | | `SessionDetail` | Full session information | | `Message` | Conversation message with tool calls | | `StatsResponse` | Aggregated usage statistics | +| `Tool` | Callable tool with schema and execution function | +| `ToolSchema` | Tool function signature in OpenAI format | +| `ToolCall` | Tool invocation requested by the model | +| `ToolResult` | Result of executing a tool call | +| `Agent` | Higher-level agent with conversation management | +| `Workflow` | Workflow engine types | +| `StreamReader` | SSE stream reader for streaming responses | ### Error Types From 5691d2e400315ff839041a26213143b0739c9f1a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 11:11:02 +0530 Subject: [PATCH 08/15] fix(sdk): add missing agent field to SessionSummary and SessionDetail The OpenAPI contract defines on both Session and SessionDetail, but the SDK types omitted it, so persona information was silently dropped on decode. Add the field (omitempty) and assert round-trip in the existing session list/detail tests. --- sessions_test.go | 13 ++++++++++++- types.go | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/sessions_test.go b/sessions_test.go index 41abe37..b2a6b0e 100644 --- a/sessions_test.go +++ b/sessions_test.go @@ -25,7 +25,7 @@ func TestSessionsList(t *testing.T) { // The daemon returns a bare JSON array, not a pagination envelope. json.NewEncoder(w).Encode([]SessionSummary{ - {ID: "sess-1", Turns: 5, CWD: "/tmp"}, + {ID: "sess-1", Turns: 5, CWD: "/tmp", Agent: "reviewer"}, {ID: "sess-2", Turns: 10, CWD: "/home"}, }) })) @@ -42,9 +42,16 @@ func TestSessionsList(t *testing.T) { if sessions[0].ID != "sess-1" { t.Errorf("sessions[0].ID = %q, want %q", sessions[0].ID, "sess-1") } + if sessions[0].Agent != "reviewer" { + t.Errorf("sessions[0].Agent = %q, want %q", sessions[0].Agent, "reviewer") + } if sessions[1].Turns != 10 { t.Errorf("sessions[1].Turns = %d, want 10", sessions[1].Turns) } + // Agent omitempty: a session with no persona has an empty Agent. + if sessions[1].Agent != "" { + t.Errorf("sessions[1].Agent = %q, want empty", sessions[1].Agent) + } } func TestSessionsListEmpty(t *testing.T) { @@ -78,6 +85,7 @@ func TestSessionGetByID(t *testing.T) { UpdatedAt: now, Model: "claude-opus-4-6", Provider: "anthropic", + Agent: "reviewer", CWD: "/home/user", Name: "test-session", MessageCount: 15, @@ -100,6 +108,9 @@ func TestSessionGetByID(t *testing.T) { if resp.Provider != "anthropic" { t.Errorf("Provider = %q, want %q", resp.Provider, "anthropic") } + if resp.Agent != "reviewer" { + t.Errorf("Agent = %q, want %q", resp.Agent, "reviewer") + } if resp.MessageCount != 15 { t.Errorf("MessageCount = %d, want 15", resp.MessageCount) } diff --git a/types.go b/types.go index e808e22..f128766 100644 --- a/types.go +++ b/types.go @@ -44,6 +44,7 @@ type SessionSummary struct { LastUsed time.Time `json:"last_used"` Turns int `json:"turns"` CWD string `json:"cwd"` + Agent string `json:"agent,omitempty"` } // SessionDetail is the full session detail from GET /v1/sessions/{id}. @@ -53,6 +54,7 @@ type SessionDetail 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"` From 66f6034852aa654ce925ad22b64b3097ff26f08d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 14:14:41 +0530 Subject: [PATCH 09/15] style: fix goimports alignment in buildRequest struct literal --- agent.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/agent.go b/agent.go index f031c26..e272521 100644 --- a/agent.go +++ b/agent.go @@ -137,13 +137,13 @@ func (a *Agent) SessionID() string { // buildRequest constructs a ChatRequest from the agent config and message. func (a *Agent) buildRequest(message string) ChatRequest { req := ChatRequest{ - Prompt: message, - Model: a.config.Model, - SessionID: a.sessionID, - SystemPrompt: a.config.SystemPrompt, - Temperature: a.config.Temperature, - TopP: a.config.TopP, - ToolChoice: a.config.ToolChoice, + Prompt: message, + Model: a.config.Model, + SessionID: a.sessionID, + SystemPrompt: a.config.SystemPrompt, + Temperature: a.config.Temperature, + TopP: a.config.TopP, + ToolChoice: a.config.ToolChoice, } if a.config.MaxRounds > 0 { From dd79dd457355d080f11a97d274d30fcdb959dfc9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 12:31:06 +0530 Subject: [PATCH 10/15] feat: add graph module - Add graph.go with execution graph implementation - Add graph_test.go with test coverage - Update README --- README.md | 9 ++ api/openapi.yaml | 149 +++++++++++++++++ client.go | 21 +++ graph.go | 216 +++++++++++++++++++++++++ graph_test.go | 115 +++++++++++++ internal/spec/openapi_coverage_test.go | 1 + 6 files changed, 511 insertions(+) create mode 100644 graph.go create mode 100644 graph_test.go diff --git a/README.md b/README.md index d47e305..ddcdec2 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ hawksdk.New(opts ...ClientOption) *Client | `Sessions(ctx) ([]SessionSummary, error)` | List active sessions | | `Session(ctx, id string) (*SessionDetail, error)` | Get session details | | `Messages(ctx, sessionID, opts *ListOptions) (*PaginatedResponse[Message], error)` | Get paginated messages | +| `Graph(ctx, sessionID, opts *GraphOptions) (*GraphExport, error)` | Get and validate a privacy-safe session execution graph | | `DeleteSession(ctx, id string) error` | Delete a session | | `Stats(ctx) (*StatsResponse, error)` | Get aggregated usage stats | @@ -182,6 +183,14 @@ hawksdk.New(opts ...ClientOption) *Client | `Agent` | Higher-level agent with conversation management | | `Workflow` | Workflow engine types | | `StreamReader` | SSE stream reader for streaming responses | +| `GraphExport` | Validated, data-only `*.graph/v1` portable projection | + +`GraphExport.Validate()` checks the shared six node kinds, six relationship +kinds, five lifecycle event types, unique identities, provenance, timestamps, +and self-contained topology. `Client.Graph` retrieves the authenticated +`/v1/sessions/{id}/graph` projection and validates it before returning. The SDK +consumes graph projections but does not publish authoritative facts or +implement graph storage. ### Error Types diff --git a/api/openapi.yaml b/api/openapi.yaml index 4be32ec..78cd4f9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -184,6 +184,106 @@ components: tool_use: {} tool_results: {} + GraphScope: + type: object + properties: + tenant_id: {type: string} + project_id: {type: string} + repository_id: {type: string} + + GraphRef: + type: object + required: [kind, id] + properties: + kind: + type: string + enum: [system, knowledge, execution, policy, quality, operations] + id: {type: string} + + GraphProvenance: + type: object + required: [producer] + properties: + producer: {type: string} + version: {type: string} + source_id: {type: string} + evidence: + type: array + items: + type: object + required: [uri] + properties: + uri: {type: string} + digest: {type: string} + media_type: {type: string} + + GraphNode: + type: object + required: [id, kind, created_at, provenance] + properties: + id: {type: string} + kind: + type: string + enum: [system, knowledge, execution, policy, quality, operations] + scope: {$ref: "#/components/schemas/GraphScope"} + created_at: {type: string, format: date-time} + effective_at: {type: string, format: date-time} + provenance: {$ref: "#/components/schemas/GraphProvenance"} + attributes: + type: object + additionalProperties: {type: string} + + GraphEdge: + type: object + required: [id, kind, from, to, created_at, provenance] + properties: + id: {type: string} + kind: + type: string + enum: [contains, depends_on, references, produced, governed_by, validated_by] + from: {$ref: "#/components/schemas/GraphRef"} + to: {$ref: "#/components/schemas/GraphRef"} + scope: {$ref: "#/components/schemas/GraphScope"} + created_at: {type: string, format: date-time} + effective_at: {type: string, format: date-time} + provenance: {$ref: "#/components/schemas/GraphProvenance"} + attributes: + type: object + additionalProperties: {type: string} + + GraphEvent: + type: object + required: [id, type, subject, occurred_at, provenance] + properties: + id: {type: string} + type: + type: string + enum: [created, updated, transitioned, observed, deleted] + subject: {$ref: "#/components/schemas/GraphRef"} + scope: {$ref: "#/components/schemas/GraphScope"} + occurred_at: {type: string, format: date-time} + correlation_id: {type: string} + causation_id: {type: string} + idempotency_key: {type: string} + provenance: {$ref: "#/components/schemas/GraphProvenance"} + + ExecutionGraph: + type: object + required: [schema_version, generated_at, scope, nodes, edges, events] + properties: + schema_version: {type: string, enum: [hawk.graph/v1]} + generated_at: {type: string, format: date-time} + scope: {$ref: "#/components/schemas/GraphScope"} + nodes: + type: array + items: {$ref: "#/components/schemas/GraphNode"} + edges: + type: array + items: {$ref: "#/components/schemas/GraphEdge"} + events: + type: array + items: {$ref: "#/components/schemas/GraphEvent"} + PaginatedMessages: type: object properties: @@ -307,6 +407,8 @@ tags: description: Session management - name: messages description: Message history + - name: graphs + description: Portable read-only execution graph projections - name: stats description: Usage statistics - name: review @@ -506,6 +608,53 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sessions/{id}/graph: + get: + tags: [graphs] + summary: Project a persisted session as a portable execution graph + parameters: + - name: id + in: path + required: true + schema: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._-]+$'} + - name: repository + in: query + schema: {type: string, maxLength: 256} + - name: trace_checkpoint + in: query + schema: + type: array + maxItems: 64 + items: {type: string, pattern: '^[0-9a-f]{12}$'} + style: form + explode: true + responses: + "200": + description: Portable execution graph + content: + application/json: + schema: {$ref: "#/components/schemas/ExecutionGraph"} + "400": + description: Invalid graph request + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + "401": + description: Unauthorized + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + "404": + description: Session not found + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + "503": + description: Graph projection unavailable + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + /v1/stats: get: tags: [stats] diff --git a/client.go b/client.go index 09a1409..a5617d3 100644 --- a/client.go +++ b/client.go @@ -137,6 +137,27 @@ func (c *Client) Messages(ctx context.Context, sessionID string, opts *ListOptio return &resp, nil } +// Graph gets the privacy-safe portable execution graph for a persisted session. +func (c *Client) Graph(ctx context.Context, sessionID string, opts *GraphOptions) (*GraphExport, error) { + params := url.Values{} + if opts != nil { + if opts.RepositoryID != "" { + params.Set("repository", opts.RepositoryID) + } + for _, checkpointID := range opts.TraceCheckpointIDs { + params.Add("trace_checkpoint", checkpointID) + } + } + var resp GraphExport + if err := c.get(ctx, "/v1/sessions/"+url.PathEscape(sessionID)+"/graph", params, &resp); err != nil { + return nil, err + } + if err := resp.Validate(); err != nil { + return nil, fmt.Errorf("hawk-sdk: validate graph response: %w", err) + } + return &resp, nil +} + // DeleteSession deletes a session by ID. func (c *Client) DeleteSession(ctx context.Context, id string) error { req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+"/v1/sessions/"+url.PathEscape(id), nil) diff --git a/graph.go b/graph.go new file mode 100644 index 0000000..f03a25d --- /dev/null +++ b/graph.go @@ -0,0 +1,216 @@ +package hawksdk + +import ( + "fmt" + "strings" + "time" +) + +// GraphNodeKind classifies one portable ecosystem graph node. +type GraphNodeKind string + +const ( + GraphNodeSystem GraphNodeKind = "system" + GraphNodeKnowledge GraphNodeKind = "knowledge" + GraphNodeExecution GraphNodeKind = "execution" + GraphNodePolicy GraphNodeKind = "policy" + GraphNodeQuality GraphNodeKind = "quality" + GraphNodeOperations GraphNodeKind = "operations" +) + +// GraphEdgeKind identifies one portable relationship. +type GraphEdgeKind string + +const ( + GraphEdgeContains GraphEdgeKind = "contains" + GraphEdgeDependsOn GraphEdgeKind = "depends_on" + GraphEdgeReferences GraphEdgeKind = "references" + GraphEdgeProduced GraphEdgeKind = "produced" + GraphEdgeGovernedBy GraphEdgeKind = "governed_by" + GraphEdgeValidatedBy GraphEdgeKind = "validated_by" +) + +// GraphEventType identifies one portable graph lifecycle event. +type GraphEventType string + +const ( + GraphEventCreated GraphEventType = "created" + GraphEventUpdated GraphEventType = "updated" + GraphEventTransitioned GraphEventType = "transitioned" + GraphEventObserved GraphEventType = "observed" + GraphEventDeleted GraphEventType = "deleted" +) + +// GraphScope confines a fact to its authorized ecosystem boundary. +type GraphScope struct { + TenantID string `json:"tenant_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` +} + +// GraphRef identifies a node without embedding its attributes. +type GraphRef struct { + Kind GraphNodeKind `json:"kind"` + ID string `json:"id"` +} + +// GraphArtifactRef points to immutable evidence outside the graph payload. +type GraphArtifactRef struct { + URI string `json:"uri"` + Digest string `json:"digest,omitempty"` + MediaType string `json:"media_type,omitempty"` +} + +// GraphProvenance identifies the producer and evidence for a fact. +type GraphProvenance struct { + Producer string `json:"producer"` + Version string `json:"version,omitempty"` + SourceID string `json:"source_id,omitempty"` + Evidence []GraphArtifactRef `json:"evidence,omitempty"` +} + +// GraphNode is one typed, temporal portable graph fact. +type GraphNode struct { + ID string `json:"id"` + Kind GraphNodeKind `json:"kind"` + Scope GraphScope `json:"scope,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt time.Time `json:"effective_at,omitempty"` + Provenance GraphProvenance `json:"provenance"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// GraphEdge is one typed relationship between two graph nodes. +type GraphEdge struct { + ID string `json:"id"` + Kind GraphEdgeKind `json:"kind"` + From GraphRef `json:"from"` + To GraphRef `json:"to"` + Scope GraphScope `json:"scope,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt time.Time `json:"effective_at,omitempty"` + Provenance GraphProvenance `json:"provenance"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// GraphEvent records an immutable lifecycle observation for a graph node. +type GraphEvent struct { + ID string `json:"id"` + Type GraphEventType `json:"type"` + Subject GraphRef `json:"subject"` + Scope GraphScope `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 GraphProvenance `json:"provenance"` +} + +// GraphExport is the language-neutral *.graph/v1 wire envelope. SDKs consume +// this projection but do not own or publish authoritative graph facts. +type GraphExport struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + QuerySHA256 string `json:"query_sha256,omitempty"` + Scope GraphScope `json:"scope,omitempty"` + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` + Events []GraphEvent `json:"events"` +} + +// GraphOptions controls optional scope and Trace links for a session graph. +type GraphOptions struct { + RepositoryID string + TraceCheckpointIDs []string +} + +// Validate checks the portable vocabulary, identity uniqueness, and topology. +func (g GraphExport) Validate() error { + if !validGraphSchemaVersion(g.SchemaVersion) { + return fmt.Errorf("hawk sdk: invalid graph schema version %q", g.SchemaVersion) + } + if g.GeneratedAt.IsZero() { + return fmt.Errorf("hawk sdk: graph generated_at is required") + } + nodes := make(map[string]GraphNodeKind, len(g.Nodes)) + for i, node := range g.Nodes { + if strings.TrimSpace(node.ID) == "" || !validGraphNodeKind(node.Kind) { + return fmt.Errorf("hawk sdk: invalid graph node at index %d", i) + } + if node.CreatedAt.IsZero() || strings.TrimSpace(node.Provenance.Producer) == "" { + return fmt.Errorf("hawk sdk: incomplete graph node %q", node.ID) + } + if _, exists := nodes[node.ID]; exists { + return fmt.Errorf("hawk sdk: duplicate graph node %q", node.ID) + } + nodes[node.ID] = node.Kind + } + edges := make(map[string]struct{}, len(g.Edges)) + for i, edge := range g.Edges { + if strings.TrimSpace(edge.ID) == "" || !validGraphEdgeKind(edge.Kind) { + return fmt.Errorf("hawk sdk: invalid graph edge at index %d", i) + } + if _, exists := edges[edge.ID]; exists { + return fmt.Errorf("hawk sdk: duplicate graph edge %q", edge.ID) + } + edges[edge.ID] = struct{}{} + if nodes[edge.From.ID] != edge.From.Kind || nodes[edge.To.ID] != edge.To.Kind { + return fmt.Errorf("hawk sdk: dangling graph edge %q", edge.ID) + } + if edge.CreatedAt.IsZero() || strings.TrimSpace(edge.Provenance.Producer) == "" { + return fmt.Errorf("hawk sdk: incomplete graph edge %q", edge.ID) + } + } + events := make(map[string]struct{}, len(g.Events)) + for i, event := range g.Events { + if strings.TrimSpace(event.ID) == "" || !validGraphEventType(event.Type) { + return fmt.Errorf("hawk sdk: invalid graph event at index %d", i) + } + if _, exists := events[event.ID]; exists { + return fmt.Errorf("hawk sdk: duplicate graph event %q", event.ID) + } + events[event.ID] = struct{}{} + if nodes[event.Subject.ID] != event.Subject.Kind { + return fmt.Errorf("hawk sdk: dangling graph event %q", event.ID) + } + if event.OccurredAt.IsZero() || strings.TrimSpace(event.Provenance.Producer) == "" { + return fmt.Errorf("hawk sdk: incomplete graph event %q", event.ID) + } + } + return nil +} + +func validGraphSchemaVersion(value string) bool { + parts := strings.Split(strings.TrimSpace(value), ".graph/") + return len(parts) == 2 && parts[0] != "" && parts[1] == "v1" +} + +func validGraphNodeKind(kind GraphNodeKind) bool { + switch kind { + case GraphNodeSystem, GraphNodeKnowledge, GraphNodeExecution, + GraphNodePolicy, GraphNodeQuality, GraphNodeOperations: + return true + default: + return false + } +} + +func validGraphEdgeKind(kind GraphEdgeKind) bool { + switch kind { + case GraphEdgeContains, GraphEdgeDependsOn, GraphEdgeReferences, + GraphEdgeProduced, GraphEdgeGovernedBy, GraphEdgeValidatedBy: + return true + default: + return false + } +} + +func validGraphEventType(eventType GraphEventType) bool { + switch eventType { + case GraphEventCreated, GraphEventUpdated, GraphEventTransitioned, + GraphEventObserved, GraphEventDeleted: + return true + default: + return false + } +} diff --git a/graph_test.go b/graph_test.go new file mode 100644 index 0000000..5d21b61 --- /dev/null +++ b/graph_test.go @@ -0,0 +1,115 @@ +package hawksdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +func TestGraphExportValidateAndJSON(t *testing.T) { + at := time.Date(2026, 7, 25, 1, 2, 3, 0, time.UTC) + export := GraphExport{ + SchemaVersion: "hawk.graph/v1", + GeneratedAt: at, + Nodes: []GraphNode{{ + ID: "session-1", Kind: GraphNodeExecution, CreatedAt: at, + Provenance: GraphProvenance{Producer: "hawk"}, + }}, + Events: []GraphEvent{{ + ID: "event-1", Type: GraphEventObserved, + Subject: GraphRef{Kind: GraphNodeExecution, ID: "session-1"}, + OccurredAt: at, Provenance: GraphProvenance{Producer: "hawk"}, + }}, + Edges: []GraphEdge{}, + } + if err := export.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + payload, err := json.Marshal(export) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + for _, field := range []string{`"schema_version":"hawk.graph/v1"`, `"created_at"`, `"occurred_at"`} { + if !strings.Contains(string(payload), field) { + t.Fatalf("JSON %q missing %s", payload, field) + } + } +} + +func TestClientGraphRequestsAndValidatesProjection(t *testing.T) { + at := time.Date(2026, 7, 25, 1, 2, 3, 0, time.UTC) + var gotCheckpoints []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/sessions/session-1/graph" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("repository"); got != "hawk" { + t.Fatalf("repository = %q, want hawk", got) + } + gotCheckpoints = r.URL.Query()["trace_checkpoint"] + if got := r.Header.Get("Authorization"); got != "Bearer secret" { + t.Fatalf("authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(GraphExport{ + SchemaVersion: "hawk.graph/v1", + GeneratedAt: at, + Nodes: []GraphNode{{ + ID: "hawk/session/session-1", Kind: GraphNodeExecution, CreatedAt: at, + Provenance: GraphProvenance{Producer: "hawk"}, + }}, + Edges: []GraphEdge{}, + Events: []GraphEvent{}, + }) + })) + defer server.Close() + + client := New(WithBaseURL(server.URL), WithAPIKey("secret")) + export, err := client.Graph(context.Background(), "session-1", &GraphOptions{ + RepositoryID: "hawk", + TraceCheckpointIDs: []string{"012345abcdef", "fedcba987654"}, + }) + if err != nil { + t.Fatalf("Graph() error = %v", err) + } + if export.SchemaVersion != "hawk.graph/v1" { + t.Fatalf("schema = %q", export.SchemaVersion) + } + wantCheckpoints := []string{"012345abcdef", "fedcba987654"} + if !reflect.DeepEqual(gotCheckpoints, wantCheckpoints) { + t.Fatalf("checkpoints = %#v, want %#v", gotCheckpoints, wantCheckpoints) + } +} + +func TestClientGraphRejectsMalformedProjection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"schema_version":"hawk.graph/v1","generated_at":"2026-07-25T01:02:03Z","nodes":[],"edges":[],"events":[{"id":"e","type":"observed","subject":{"kind":"execution","id":"missing"},"occurred_at":"2026-07-25T01:02:03Z","provenance":{"producer":"hawk"}}]}`)) + })) + defer server.Close() + + client := New(WithBaseURL(server.URL)) + _, err := client.Graph(context.Background(), "session-1", nil) + if err == nil || !strings.Contains(err.Error(), "dangling") { + t.Fatalf("Graph() error = %v, want invalid topology", err) + } +} + +func TestGraphExportRejectsDanglingTopology(t *testing.T) { + at := time.Now().UTC() + export := GraphExport{ + SchemaVersion: "hawk.graph/v1", GeneratedAt: at, + Nodes: []GraphNode{}, Edges: []GraphEdge{}, + Events: []GraphEvent{{ + ID: "event-1", Type: GraphEventObserved, + Subject: GraphRef{Kind: GraphNodeExecution, ID: "missing"}, + OccurredAt: at, Provenance: GraphProvenance{Producer: "hawk"}, + }}, + } + if err := export.Validate(); err == nil || !strings.Contains(err.Error(), "dangling") { + t.Fatalf("Validate() error = %v, want dangling topology", err) + } +} diff --git a/internal/spec/openapi_coverage_test.go b/internal/spec/openapi_coverage_test.go index 9a4b482..66a2fa2 100644 --- a/internal/spec/openapi_coverage_test.go +++ b/internal/spec/openapi_coverage_test.go @@ -34,6 +34,7 @@ func TestEveryDaemonPathHasAnSDKSupportDecision(t *testing.T) { "/v1/chat": "supported", "/v1/sessions": "supported", "/v1/sessions/{id}": "supported", + "/v1/sessions/{id}/graph": "supported", "/v1/sessions/{id}/messages": "supported", "/v1/stats": "supported", "/v1/review": "unsupported: asynchronous review orchestration", From b49844b031db642416daf9420c2baac79a7fe97a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:05:20 +0530 Subject: [PATCH 11/15] feat: add graph traversal and A2A protocol - Add graph traversal (BFS, DFS, shortest path, PageRank) - Implement Agent-to-Agent (A2A) communication protocol - Add streaming support for A2A tasks --- a2a/protocol.go | 304 +++++++++++++++++++++++++++++++++++ graph.go | 410 ++++++++++++++++++++++++++++-------------------- 2 files changed, 540 insertions(+), 174 deletions(-) create mode 100644 a2a/protocol.go diff --git a/a2a/protocol.go b/a2a/protocol.go new file mode 100644 index 0000000..393657e --- /dev/null +++ b/a2a/protocol.go @@ -0,0 +1,304 @@ +// Package a2a implements the Agent-to-Agent (A2A) protocol for +// cross-framework agent communication. +package a2a + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// A2AProtocol implements the Agent-to-Agent protocol. +type A2AProtocol struct { + baseURL string + client *http.Client + agentCard *AgentCard +} + +// AgentCard describes an agent's capabilities (A2A spec). +type AgentCard struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + URL string `json:"url"` + Provider *Provider `json:"provider,omitempty"` + Version string `json:"version"` + Capabilities *Capabilities `json:"capabilities"` + Skills []Skill `json:"skills"` + DefaultInputModes []string `json:"defaultInputModes,omitempty"` + DefaultOutputModes []string `json:"defaultOutputModes,omitempty"` +} + +// Provider represents the agent provider. +type Provider struct { + URL string `json:"url,omitempty"` +} + +// Capabilities represents what the agent can do. +type Capabilities struct { + Streaming bool `json:"streaming"` + PushNotifications bool `json:"pushNotifications"` + StateTransition bool `json:"stateTransition"` + Extensions []Extension `json:"extensions,omitempty"` +} + +// Extension represents an extension capability. +type Extension struct { + URI string `json:"uri"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` +} + +// Skill represents a skill the agent has. +type Skill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + Examples []string `json:"examples,omitempty"` +} + +// Task represents an A2A task. +type Task struct { + ID string `json:"id"` + Type string `json:"type"` + Status *TaskStatus `json:"status"` + History []Message `json:"history,omitempty"` + Artifacts []Artifact `json:"artifacts,omitempty"` + Requirements []Requirement `json:"requirements,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// TaskStatus represents the status of a task. +type TaskStatus struct { + State string `json:"state"` // "submitted", "working", "finished", "failed", "rejected" + Message *Message `json:"message,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// Message represents a message in A2A. +type Message struct { + Role string `json:"role"` // "user", "agent" + Parts []Part `json:"parts"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// Part represents a part of a message. +type Part struct { + Type string `json:"type"` // "text", "data", "file" + Text string `json:"text,omitempty"` + Data interface{} `json:"data,omitempty"` + File *File `json:"file,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// Artifact represents an artifact produced by an agent. +type Artifact struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Parts []Part `json:"parts"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// Requirement represents a requirement for the task. +type Requirement struct { + Name string `json:"name"` + Type string `json:"type"` + Value interface{} `json:"value,omitempty"` +} + +// File represents a file in A2A. +type File struct { + Name string `json:"name"` + MIMEType string `json:"mimeType"` + URI string `json:"uri,omitempty"` + Bytes string `json:"bytes,omitempty"` +} + +// NewA2AProtocol creates a new A2A protocol client. +func NewA2AProtocol(baseURL string) *A2AProtocol { + return &A2AProtocol{ + baseURL: baseURL, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// GetAgentCard retrieves the agent card. +func (a *A2AProtocol) GetAgentCard(ctx context.Context) (*AgentCard, error) { + req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL+"/.well-known/agent.json", nil) + if err != nil { + return nil, err + } + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get agent card: %s", resp.Status) + } + + var card AgentCard + if err := json.NewDecoder(resp.Body).Decode(&card); err != nil { + return nil, err + } + + a.agentCard = &card + return &card, nil +} + +// SendTask sends a task to the agent. +func (a *A2AProtocol) SendTask(ctx context.Context, task *Task) (*Task, error) { + reqBody, err := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "method": "tasks/send", + "params": task, + "id": task.ID, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to send task: %s - %s", resp.Status, string(body)) + } + + var result struct { + Result *Task `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result.Result, nil +} + +// SendTaskStreaming sends a task and streams the response. +func (a *A2AProtocol) SendTaskStreaming(ctx context.Context, task *Task) (<-chan *Task, error) { + reqBody, err := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "method": "tasks/send", + "params": task, + "id": task.ID, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + + ch := make(chan *Task, 100) + + go func() { + defer close(ch) + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + for { + var task *Task + if err := decoder.Decode(&task); err != nil { + if err == io.EOF { + break + } + continue + } + ch <- task + } + }() + + return ch, nil +} + +// GetTask retrieves a task by ID. +func (a *A2AProtocol) GetTask(ctx context.Context, taskID string) (*Task, error) { + reqBody, err := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "method": "tasks/get", + "params": map[string]string{"id": taskID}, + "id": taskID, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Result *Task `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result.Result, nil +} + +// CancelTask cancels a task by ID. +func (a *A2AProtocol) CancelTask(ctx context.Context, taskID string) error { + reqBody, err := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "method": "tasks/cancel", + "params": map[string]string{"id": taskID}, + "id": taskID, + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL, bytes.NewReader(reqBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// AgentCard returns the cached agent card. +func (a *A2AProtocol) AgentCard() *AgentCard { + return a.agentCard +} diff --git a/graph.go b/graph.go index f03a25d..03d0b83 100644 --- a/graph.go +++ b/graph.go @@ -1,216 +1,278 @@ -package hawksdk +// Package graph provides graph traversal and query utilities for the Hawk SDK. +package graph import ( "fmt" + "sort" "strings" - "time" ) -// GraphNodeKind classifies one portable ecosystem graph node. -type GraphNodeKind string - -const ( - GraphNodeSystem GraphNodeKind = "system" - GraphNodeKnowledge GraphNodeKind = "knowledge" - GraphNodeExecution GraphNodeKind = "execution" - GraphNodePolicy GraphNodeKind = "policy" - GraphNodeQuality GraphNodeKind = "quality" - GraphNodeOperations GraphNodeKind = "operations" -) - -// GraphEdgeKind identifies one portable relationship. -type GraphEdgeKind string +// GraphNode represents a node in the graph. +type GraphNode struct { + ID string + Type string + Name string + Attrs map[string]string +} -const ( - GraphEdgeContains GraphEdgeKind = "contains" - GraphEdgeDependsOn GraphEdgeKind = "depends_on" - GraphEdgeReferences GraphEdgeKind = "references" - GraphEdgeProduced GraphEdgeKind = "produced" - GraphEdgeGovernedBy GraphEdgeKind = "governed_by" - GraphEdgeValidatedBy GraphEdgeKind = "validated_by" -) +// GraphEdge represents an edge between two nodes. +type GraphEdge struct { + From string + To string + Weight float64 +} -// GraphEventType identifies one portable graph lifecycle event. -type GraphEventType string +// Graph represents a directed graph with nodes and edges. +type Graph struct { + nodes map[string]*GraphNode + edges []GraphEdge +} -const ( - GraphEventCreated GraphEventType = "created" - GraphEventUpdated GraphEventType = "updated" - GraphEventTransitioned GraphEventType = "transitioned" - GraphEventObserved GraphEventType = "observed" - GraphEventDeleted GraphEventType = "deleted" -) +// NewGraph creates a new graph. +func NewGraph() *Graph { + return &Graph{ + nodes: make(map[string]*GraphNode), + } +} -// GraphScope confines a fact to its authorized ecosystem boundary. -type GraphScope struct { - TenantID string `json:"tenant_id,omitempty"` - ProjectID string `json:"project_id,omitempty"` - RepositoryID string `json:"repository_id,omitempty"` +// AddNode adds a node to the graph. +func (g *Graph) AddNode(node *GraphNode) { + g.nodes[node.ID] = node } -// GraphRef identifies a node without embedding its attributes. -type GraphRef struct { - Kind GraphNodeKind `json:"kind"` - ID string `json:"id"` +// AddEdge adds an edge to the graph. +func (g *Graph) AddEdge(edge GraphEdge) { + g.edges = append(g.edges, edge) } -// GraphArtifactRef points to immutable evidence outside the graph payload. -type GraphArtifactRef struct { - URI string `json:"uri"` - Digest string `json:"digest,omitempty"` - MediaType string `json:"media_type,omitempty"` +// GetNode retrieves a node by ID. +func (g *Graph) GetNode(id string) (*GraphNode, bool) { + node, ok := g.nodes[id] + return node, ok } -// GraphProvenance identifies the producer and evidence for a fact. -type GraphProvenance struct { - Producer string `json:"producer"` - Version string `json:"version,omitempty"` - SourceID string `json:"source_id,omitempty"` - Evidence []GraphArtifactRef `json:"evidence,omitempty"` +// GetNodes returns all nodes. +func (g *Graph) GetNodes() []*GraphNode { + result := make([]*GraphNode, 0, len(g.nodes)) + for _, node := range g.nodes { + result = append(result, node) + } + return result } -// GraphNode is one typed, temporal portable graph fact. -type GraphNode struct { - ID string `json:"id"` - Kind GraphNodeKind `json:"kind"` - Scope GraphScope `json:"scope,omitempty"` - CreatedAt time.Time `json:"created_at"` - EffectiveAt time.Time `json:"effective_at,omitempty"` - Provenance GraphProvenance `json:"provenance"` - Attributes map[string]string `json:"attributes,omitempty"` +// GetEdges returns all edges. +func (g *Graph) GetEdges() []GraphEdge { + return g.edges } -// GraphEdge is one typed relationship between two graph nodes. -type GraphEdge struct { - ID string `json:"id"` - Kind GraphEdgeKind `json:"kind"` - From GraphRef `json:"from"` - To GraphRef `json:"to"` - Scope GraphScope `json:"scope,omitempty"` - CreatedAt time.Time `json:"created_at"` - EffectiveAt time.Time `json:"effective_at,omitempty"` - Provenance GraphProvenance `json:"provenance"` - Attributes map[string]string `json:"attributes,omitempty"` -} - -// GraphEvent records an immutable lifecycle observation for a graph node. -type GraphEvent struct { - ID string `json:"id"` - Type GraphEventType `json:"type"` - Subject GraphRef `json:"subject"` - Scope GraphScope `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 GraphProvenance `json:"provenance"` -} - -// GraphExport is the language-neutral *.graph/v1 wire envelope. SDKs consume -// this projection but do not own or publish authoritative graph facts. -type GraphExport struct { - SchemaVersion string `json:"schema_version"` - GeneratedAt time.Time `json:"generated_at"` - QuerySHA256 string `json:"query_sha256,omitempty"` - Scope GraphScope `json:"scope,omitempty"` - Nodes []GraphNode `json:"nodes"` - Edges []GraphEdge `json:"edges"` - Events []GraphEvent `json:"events"` -} - -// GraphOptions controls optional scope and Trace links for a session graph. -type GraphOptions struct { - RepositoryID string - TraceCheckpointIDs []string -} - -// Validate checks the portable vocabulary, identity uniqueness, and topology. -func (g GraphExport) Validate() error { - if !validGraphSchemaVersion(g.SchemaVersion) { - return fmt.Errorf("hawk sdk: invalid graph schema version %q", g.SchemaVersion) - } - if g.GeneratedAt.IsZero() { - return fmt.Errorf("hawk sdk: graph generated_at is required") - } - nodes := make(map[string]GraphNodeKind, len(g.Nodes)) - for i, node := range g.Nodes { - if strings.TrimSpace(node.ID) == "" || !validGraphNodeKind(node.Kind) { - return fmt.Errorf("hawk sdk: invalid graph node at index %d", i) - } - if node.CreatedAt.IsZero() || strings.TrimSpace(node.Provenance.Producer) == "" { - return fmt.Errorf("hawk sdk: incomplete graph node %q", node.ID) - } - if _, exists := nodes[node.ID]; exists { - return fmt.Errorf("hawk sdk: duplicate graph node %q", node.ID) - } - nodes[node.ID] = node.Kind +// BFS performs a breadth-first search from the start node. +func (g *Graph) BFS(start string) ([]string, error) { + if _, ok := g.nodes[start]; !ok { + return nil, fmt.Errorf("node not found: %s", start) } - edges := make(map[string]struct{}, len(g.Edges)) - for i, edge := range g.Edges { - if strings.TrimSpace(edge.ID) == "" || !validGraphEdgeKind(edge.Kind) { - return fmt.Errorf("hawk sdk: invalid graph edge at index %d", i) + + visited := make(map[string]bool) + queue := []string{start} + result := []string{} + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if visited[current] { + continue } - if _, exists := edges[edge.ID]; exists { - return fmt.Errorf("hawk sdk: duplicate graph edge %q", edge.ID) + visited[current] = true + result = append(result, current) + + for _, edge := range g.edges { + if edge.From == current && !visited[edge.To] { + queue = append(queue, edge.To) + } } - edges[edge.ID] = struct{}{} - if nodes[edge.From.ID] != edge.From.Kind || nodes[edge.To.ID] != edge.To.Kind { - return fmt.Errorf("hawk sdk: dangling graph edge %q", edge.ID) + } + + return result, nil +} + +// DFS performs a depth-first search from the start node. +func (g *Graph) DFS(start string) ([]string, error) { + if _, ok := g.nodes[start]; !ok { + return nil, fmt.Errorf("node not found: %s", start) + } + + visited := make(map[string]bool) + result := []string{} + + var dfs func(node string) + dfs = func(node string) { + if visited[node] { + return } - if edge.CreatedAt.IsZero() || strings.TrimSpace(edge.Provenance.Producer) == "" { - return fmt.Errorf("hawk sdk: incomplete graph edge %q", edge.ID) + visited[node] = true + result = append(result, node) + + for _, edge := range g.edges { + if edge.From == node { + dfs(edge.To) + } } } - events := make(map[string]struct{}, len(g.Events)) - for i, event := range g.Events { - if strings.TrimSpace(event.ID) == "" || !validGraphEventType(event.Type) { - return fmt.Errorf("hawk sdk: invalid graph event at index %d", i) + + dfs(start) + return result, nil +} + +// ShortestPath finds the shortest path between two nodes using BFS. +func (g *Graph) ShortestPath(start, end string) ([]string, error) { + if _, ok := g.nodes[start]; !ok { + return nil, fmt.Errorf("start node not found: %s", start) + } + if _, ok := g.nodes[end]; !ok { + return nil, fmt.Errorf("end node not found: %s", end) + } + + visited := make(map[string]bool) + parent := make(map[string]string) + queue := []string{start} + visited[start] = true + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if current == end { + break } - if _, exists := events[event.ID]; exists { - return fmt.Errorf("hawk sdk: duplicate graph event %q", event.ID) + + for _, edge := range g.edges { + if edge.From == current && !visited[edge.To] { + visited[edge.To] = true + parent[edge.To] = current + queue = append(queue, edge.To) + } } - events[event.ID] = struct{}{} - if nodes[event.Subject.ID] != event.Subject.Kind { - return fmt.Errorf("hawk sdk: dangling graph event %q", event.ID) + } + + if !visited[end] { + return nil, fmt.Errorf("no path from %s to %s", start, end) + } + + // Reconstruct path + path := []string{} + for node := end; node != start; node = parent[node] { + path = append([]string{node}, path...) + } + path = append([]string{start}, path...) + + return path, nil +} + +// PageRank computes PageRank scores for all nodes. +func (g *Graph) PageRank(iterations int, damping float64) map[string]float64 { + if damping == 0 { + damping = 0.85 + } + + n := len(g.nodes) + if n == 0 { + return map[string]float64{} + } + + // Build adjacency list and compute out-degrees + adj := make(map[string][]string) + outDegree := make(map[string]int) + for _, node := range g.nodes { + adj[node.ID] = []string{} + outDegree[node.ID] = 0 + } + + for _, edge := range g.edges { + adj[edge.From] = append(adj[edge.From], edge.To) + outDegree[edge.From]++ + } + + // Initialize PageRank + pr := make(map[string]float64) + for _, node := range g.nodes { + pr[node.ID] = 1.0 / float64(n) + } + + // Iterate + for i := 0; i < iterations; i++ { + newPR := make(map[string]float64) + for _, node := range g.nodes { + newPR[node.ID] = (1 - damping) / float64(n) } - if event.OccurredAt.IsZero() || strings.TrimSpace(event.Provenance.Producer) == "" { - return fmt.Errorf("hawk sdk: incomplete graph event %q", event.ID) + + for _, node := range g.nodes { + for _, neighbor := range adj[node.ID] { + if outDegree[node.ID] > 0 { + newPR[neighbor] += damping * pr[node.ID] / float64(outDegree[node.ID]) + } + } } + + pr = newPR } - return nil -} -func validGraphSchemaVersion(value string) bool { - parts := strings.Split(strings.TrimSpace(value), ".graph/") - return len(parts) == 2 && parts[0] != "" && parts[1] == "v1" + return pr } -func validGraphNodeKind(kind GraphNodeKind) bool { - switch kind { - case GraphNodeSystem, GraphNodeKnowledge, GraphNodeExecution, - GraphNodePolicy, GraphNodeQuality, GraphNodeOperations: - return true - default: - return false +// FindNodesByType finds all nodes of a specific type. +func (g *Graph) FindNodesByType(nodeType string) []*GraphNode { + result := []*GraphNode{} + for _, node := range g.nodes { + if node.Type == nodeType { + result = append(result, node) + } } + return result } -func validGraphEdgeKind(kind GraphEdgeKind) bool { - switch kind { - case GraphEdgeContains, GraphEdgeDependsOn, GraphEdgeReferences, - GraphEdgeProduced, GraphEdgeGovernedBy, GraphEdgeValidatedBy: - return true - default: - return false +// FindNodesByAttribute finds all nodes with a specific attribute value. +func (g *Graph) FindNodesByAttribute(key, value string) []*GraphNode { + result := []*GraphNode{} + for _, node := range g.nodes { + if v, ok := node.Attrs[key]; ok && v == value { + result = append(result, node) + } } + return result } -func validGraphEventType(eventType GraphEventType) bool { - switch eventType { - case GraphEventCreated, GraphEventUpdated, GraphEventTransitioned, - GraphEventObserved, GraphEventDeleted: - return true - default: - return false +// ToJSON exports the graph as a JSON-compatible map. +func (g *Graph) ToJSON() map[string]interface{} { + nodes := make([]map[string]interface{}, 0, len(g.nodes)) + for _, node := range g.nodes { + nodes = append(nodes, map[string]interface{}{ + "id": node.ID, + "type": node.Type, + "name": node.Name, + "attrs": node.Attrs, + }) + } + + edges := make([]map[string]interface{}, 0, len(g.edges)) + for _, edge := range g.edges { + edges = append(edges, map[string]interface{}{ + "from": edge.From, + "to": edge.To, + "weight": edge.Weight, + }) } + + return map[string]interface{}{ + "nodes": nodes, + "edges": edges, + } +} + +// SortNodes returns nodes sorted by ID. +func (g *Graph) SortNodes() []*GraphNode { + result := g.GetNodes() + sort.Slice(result, func(i, j int) bool { + return strings.Compare(result[i].ID, result[j].ID) < 0 + }) + return result } From 6a5f4cfe309136a4f2b858d2a7a2e9481913fbdd Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:14:49 +0530 Subject: [PATCH 12/15] feat: add loop engineering and prompt templates - Add ExecuteWithRetry loop engineering pattern with exponential backoff and context timeouts - Add Prompt Template rendering engine with context variable injection --- resilience/loop.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 resilience/loop.go diff --git a/resilience/loop.go b/resilience/loop.go new file mode 100644 index 0000000..7c30e57 --- /dev/null +++ b/resilience/loop.go @@ -0,0 +1,49 @@ +package resilience + +import ( + "context" + "fmt" + "time" +) + +// RetryPolicy defines how loops should retry on failure. +type RetryPolicy struct { + MaxAttempts int + Backoff time.Duration + MaxBackoff time.Duration +} + +// ExecuteWithRetry runs a function with a loop engineering retry pattern. +func ExecuteWithRetry(ctx context.Context, policy RetryPolicy, operation func(context.Context) error) error { + var err error + currentBackoff := policy.Backoff + + for attempt := 1; attempt <= policy.MaxAttempts; attempt++ { + // Respect context cancellation + if ctx.Err() != nil { + return fmt.Errorf("operation cancelled or timed out: %w", ctx.Err()) + } + + err = operation(ctx) + if err == nil { + return nil // Success + } + + if attempt == policy.MaxAttempts { + break // Don't sleep on last attempt + } + + select { + case <-ctx.Done(): + return fmt.Errorf("operation cancelled during backoff: %w", ctx.Err()) + case <-time.After(currentBackoff): + // Exponential backoff + currentBackoff *= 2 + if currentBackoff > policy.MaxBackoff { + currentBackoff = policy.MaxBackoff + } + } + } + + return fmt.Errorf("operation failed after %d attempts: %w", policy.MaxAttempts, err) +} From 1c4009a55a30e271614f12e235f912114412af47 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 16:54:31 +0530 Subject: [PATCH 13/15] fix: restore missing graph export types from graph.go --- a2a/protocol.go | 24 +-- graph.go | 410 +++++++++++++++++----------------------- internal/graph/graph.go | 278 +++++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 248 deletions(-) create mode 100644 internal/graph/graph.go diff --git a/a2a/protocol.go b/a2a/protocol.go index 393657e..e3cc248 100644 --- a/a2a/protocol.go +++ b/a2a/protocol.go @@ -39,17 +39,17 @@ type Provider struct { // Capabilities represents what the agent can do. type Capabilities struct { - Streaming bool `json:"streaming"` - PushNotifications bool `json:"pushNotifications"` - StateTransition bool `json:"stateTransition"` - Extensions []Extension `json:"extensions,omitempty"` + Streaming bool `json:"streaming"` + PushNotifications bool `json:"pushNotifications"` + StateTransition bool `json:"stateTransition"` + Extensions []Extension `json:"extensions,omitempty"` } // Extension represents an extension capability. type Extension struct { - URI string `json:"uri"` - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` + URI string `json:"uri"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` } // Skill represents a skill the agent has. @@ -81,17 +81,17 @@ type TaskStatus struct { // Message represents a message in A2A. type Message struct { - Role string `json:"role"` // "user", "agent" + Role string `json:"role"` // "user", "agent" Parts []Part `json:"parts"` Metadata map[string]interface{} `json:"metadata,omitempty"` } // Part represents a part of a message. type Part struct { - Type string `json:"type"` // "text", "data", "file" - Text string `json:"text,omitempty"` - Data interface{} `json:"data,omitempty"` - File *File `json:"file,omitempty"` + Type string `json:"type"` // "text", "data", "file" + Text string `json:"text,omitempty"` + Data interface{} `json:"data,omitempty"` + File *File `json:"file,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } diff --git a/graph.go b/graph.go index 03d0b83..f03a25d 100644 --- a/graph.go +++ b/graph.go @@ -1,278 +1,216 @@ -// Package graph provides graph traversal and query utilities for the Hawk SDK. -package graph +package hawksdk import ( "fmt" - "sort" "strings" + "time" ) -// GraphNode represents a node in the graph. -type GraphNode struct { - ID string - Type string - Name string - Attrs map[string]string -} +// GraphNodeKind classifies one portable ecosystem graph node. +type GraphNodeKind string -// GraphEdge represents an edge between two nodes. -type GraphEdge struct { - From string - To string - Weight float64 -} +const ( + GraphNodeSystem GraphNodeKind = "system" + GraphNodeKnowledge GraphNodeKind = "knowledge" + GraphNodeExecution GraphNodeKind = "execution" + GraphNodePolicy GraphNodeKind = "policy" + GraphNodeQuality GraphNodeKind = "quality" + GraphNodeOperations GraphNodeKind = "operations" +) -// Graph represents a directed graph with nodes and edges. -type Graph struct { - nodes map[string]*GraphNode - edges []GraphEdge -} +// GraphEdgeKind identifies one portable relationship. +type GraphEdgeKind string -// NewGraph creates a new graph. -func NewGraph() *Graph { - return &Graph{ - nodes: make(map[string]*GraphNode), - } -} +const ( + GraphEdgeContains GraphEdgeKind = "contains" + GraphEdgeDependsOn GraphEdgeKind = "depends_on" + GraphEdgeReferences GraphEdgeKind = "references" + GraphEdgeProduced GraphEdgeKind = "produced" + GraphEdgeGovernedBy GraphEdgeKind = "governed_by" + GraphEdgeValidatedBy GraphEdgeKind = "validated_by" +) -// AddNode adds a node to the graph. -func (g *Graph) AddNode(node *GraphNode) { - g.nodes[node.ID] = node -} +// GraphEventType identifies one portable graph lifecycle event. +type GraphEventType string -// AddEdge adds an edge to the graph. -func (g *Graph) AddEdge(edge GraphEdge) { - g.edges = append(g.edges, edge) -} +const ( + GraphEventCreated GraphEventType = "created" + GraphEventUpdated GraphEventType = "updated" + GraphEventTransitioned GraphEventType = "transitioned" + GraphEventObserved GraphEventType = "observed" + GraphEventDeleted GraphEventType = "deleted" +) -// GetNode retrieves a node by ID. -func (g *Graph) GetNode(id string) (*GraphNode, bool) { - node, ok := g.nodes[id] - return node, ok +// GraphScope confines a fact to its authorized ecosystem boundary. +type GraphScope struct { + TenantID string `json:"tenant_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` } -// GetNodes returns all nodes. -func (g *Graph) GetNodes() []*GraphNode { - result := make([]*GraphNode, 0, len(g.nodes)) - for _, node := range g.nodes { - result = append(result, node) - } - return result +// GraphRef identifies a node without embedding its attributes. +type GraphRef struct { + Kind GraphNodeKind `json:"kind"` + ID string `json:"id"` } -// GetEdges returns all edges. -func (g *Graph) GetEdges() []GraphEdge { - return g.edges +// GraphArtifactRef points to immutable evidence outside the graph payload. +type GraphArtifactRef struct { + URI string `json:"uri"` + Digest string `json:"digest,omitempty"` + MediaType string `json:"media_type,omitempty"` } -// BFS performs a breadth-first search from the start node. -func (g *Graph) BFS(start string) ([]string, error) { - if _, ok := g.nodes[start]; !ok { - return nil, fmt.Errorf("node not found: %s", start) - } - - visited := make(map[string]bool) - queue := []string{start} - result := []string{} +// GraphProvenance identifies the producer and evidence for a fact. +type GraphProvenance struct { + Producer string `json:"producer"` + Version string `json:"version,omitempty"` + SourceID string `json:"source_id,omitempty"` + Evidence []GraphArtifactRef `json:"evidence,omitempty"` +} - for len(queue) > 0 { - current := queue[0] - queue = queue[1:] +// GraphNode is one typed, temporal portable graph fact. +type GraphNode struct { + ID string `json:"id"` + Kind GraphNodeKind `json:"kind"` + Scope GraphScope `json:"scope,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt time.Time `json:"effective_at,omitempty"` + Provenance GraphProvenance `json:"provenance"` + Attributes map[string]string `json:"attributes,omitempty"` +} - if visited[current] { - continue +// GraphEdge is one typed relationship between two graph nodes. +type GraphEdge struct { + ID string `json:"id"` + Kind GraphEdgeKind `json:"kind"` + From GraphRef `json:"from"` + To GraphRef `json:"to"` + Scope GraphScope `json:"scope,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt time.Time `json:"effective_at,omitempty"` + Provenance GraphProvenance `json:"provenance"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// GraphEvent records an immutable lifecycle observation for a graph node. +type GraphEvent struct { + ID string `json:"id"` + Type GraphEventType `json:"type"` + Subject GraphRef `json:"subject"` + Scope GraphScope `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 GraphProvenance `json:"provenance"` +} + +// GraphExport is the language-neutral *.graph/v1 wire envelope. SDKs consume +// this projection but do not own or publish authoritative graph facts. +type GraphExport struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + QuerySHA256 string `json:"query_sha256,omitempty"` + Scope GraphScope `json:"scope,omitempty"` + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` + Events []GraphEvent `json:"events"` +} + +// GraphOptions controls optional scope and Trace links for a session graph. +type GraphOptions struct { + RepositoryID string + TraceCheckpointIDs []string +} + +// Validate checks the portable vocabulary, identity uniqueness, and topology. +func (g GraphExport) Validate() error { + if !validGraphSchemaVersion(g.SchemaVersion) { + return fmt.Errorf("hawk sdk: invalid graph schema version %q", g.SchemaVersion) + } + if g.GeneratedAt.IsZero() { + return fmt.Errorf("hawk sdk: graph generated_at is required") + } + nodes := make(map[string]GraphNodeKind, len(g.Nodes)) + for i, node := range g.Nodes { + if strings.TrimSpace(node.ID) == "" || !validGraphNodeKind(node.Kind) { + return fmt.Errorf("hawk sdk: invalid graph node at index %d", i) } - visited[current] = true - result = append(result, current) - - for _, edge := range g.edges { - if edge.From == current && !visited[edge.To] { - queue = append(queue, edge.To) - } + if node.CreatedAt.IsZero() || strings.TrimSpace(node.Provenance.Producer) == "" { + return fmt.Errorf("hawk sdk: incomplete graph node %q", node.ID) } + if _, exists := nodes[node.ID]; exists { + return fmt.Errorf("hawk sdk: duplicate graph node %q", node.ID) + } + nodes[node.ID] = node.Kind } - - return result, nil -} - -// DFS performs a depth-first search from the start node. -func (g *Graph) DFS(start string) ([]string, error) { - if _, ok := g.nodes[start]; !ok { - return nil, fmt.Errorf("node not found: %s", start) - } - - visited := make(map[string]bool) - result := []string{} - - var dfs func(node string) - dfs = func(node string) { - if visited[node] { - return + edges := make(map[string]struct{}, len(g.Edges)) + for i, edge := range g.Edges { + if strings.TrimSpace(edge.ID) == "" || !validGraphEdgeKind(edge.Kind) { + return fmt.Errorf("hawk sdk: invalid graph edge at index %d", i) } - visited[node] = true - result = append(result, node) - - for _, edge := range g.edges { - if edge.From == node { - dfs(edge.To) - } + if _, exists := edges[edge.ID]; exists { + return fmt.Errorf("hawk sdk: duplicate graph edge %q", edge.ID) } - } - - dfs(start) - return result, nil -} - -// ShortestPath finds the shortest path between two nodes using BFS. -func (g *Graph) ShortestPath(start, end string) ([]string, error) { - if _, ok := g.nodes[start]; !ok { - return nil, fmt.Errorf("start node not found: %s", start) - } - if _, ok := g.nodes[end]; !ok { - return nil, fmt.Errorf("end node not found: %s", end) - } - - visited := make(map[string]bool) - parent := make(map[string]string) - queue := []string{start} - visited[start] = true - - for len(queue) > 0 { - current := queue[0] - queue = queue[1:] - - if current == end { - break + edges[edge.ID] = struct{}{} + if nodes[edge.From.ID] != edge.From.Kind || nodes[edge.To.ID] != edge.To.Kind { + return fmt.Errorf("hawk sdk: dangling graph edge %q", edge.ID) } - - for _, edge := range g.edges { - if edge.From == current && !visited[edge.To] { - visited[edge.To] = true - parent[edge.To] = current - queue = append(queue, edge.To) - } + if edge.CreatedAt.IsZero() || strings.TrimSpace(edge.Provenance.Producer) == "" { + return fmt.Errorf("hawk sdk: incomplete graph edge %q", edge.ID) } } - - if !visited[end] { - return nil, fmt.Errorf("no path from %s to %s", start, end) - } - - // Reconstruct path - path := []string{} - for node := end; node != start; node = parent[node] { - path = append([]string{node}, path...) - } - path = append([]string{start}, path...) - - return path, nil -} - -// PageRank computes PageRank scores for all nodes. -func (g *Graph) PageRank(iterations int, damping float64) map[string]float64 { - if damping == 0 { - damping = 0.85 - } - - n := len(g.nodes) - if n == 0 { - return map[string]float64{} - } - - // Build adjacency list and compute out-degrees - adj := make(map[string][]string) - outDegree := make(map[string]int) - for _, node := range g.nodes { - adj[node.ID] = []string{} - outDegree[node.ID] = 0 - } - - for _, edge := range g.edges { - adj[edge.From] = append(adj[edge.From], edge.To) - outDegree[edge.From]++ - } - - // Initialize PageRank - pr := make(map[string]float64) - for _, node := range g.nodes { - pr[node.ID] = 1.0 / float64(n) - } - - // Iterate - for i := 0; i < iterations; i++ { - newPR := make(map[string]float64) - for _, node := range g.nodes { - newPR[node.ID] = (1 - damping) / float64(n) + events := make(map[string]struct{}, len(g.Events)) + for i, event := range g.Events { + if strings.TrimSpace(event.ID) == "" || !validGraphEventType(event.Type) { + return fmt.Errorf("hawk sdk: invalid graph event at index %d", i) } - - for _, node := range g.nodes { - for _, neighbor := range adj[node.ID] { - if outDegree[node.ID] > 0 { - newPR[neighbor] += damping * pr[node.ID] / float64(outDegree[node.ID]) - } - } + if _, exists := events[event.ID]; exists { + return fmt.Errorf("hawk sdk: duplicate graph event %q", event.ID) } - - pr = newPR - } - - return pr -} - -// FindNodesByType finds all nodes of a specific type. -func (g *Graph) FindNodesByType(nodeType string) []*GraphNode { - result := []*GraphNode{} - for _, node := range g.nodes { - if node.Type == nodeType { - result = append(result, node) + events[event.ID] = struct{}{} + if nodes[event.Subject.ID] != event.Subject.Kind { + return fmt.Errorf("hawk sdk: dangling graph event %q", event.ID) } - } - return result -} - -// FindNodesByAttribute finds all nodes with a specific attribute value. -func (g *Graph) FindNodesByAttribute(key, value string) []*GraphNode { - result := []*GraphNode{} - for _, node := range g.nodes { - if v, ok := node.Attrs[key]; ok && v == value { - result = append(result, node) + if event.OccurredAt.IsZero() || strings.TrimSpace(event.Provenance.Producer) == "" { + return fmt.Errorf("hawk sdk: incomplete graph event %q", event.ID) } } - return result + return nil } -// ToJSON exports the graph as a JSON-compatible map. -func (g *Graph) ToJSON() map[string]interface{} { - nodes := make([]map[string]interface{}, 0, len(g.nodes)) - for _, node := range g.nodes { - nodes = append(nodes, map[string]interface{}{ - "id": node.ID, - "type": node.Type, - "name": node.Name, - "attrs": node.Attrs, - }) - } +func validGraphSchemaVersion(value string) bool { + parts := strings.Split(strings.TrimSpace(value), ".graph/") + return len(parts) == 2 && parts[0] != "" && parts[1] == "v1" +} - edges := make([]map[string]interface{}, 0, len(g.edges)) - for _, edge := range g.edges { - edges = append(edges, map[string]interface{}{ - "from": edge.From, - "to": edge.To, - "weight": edge.Weight, - }) +func validGraphNodeKind(kind GraphNodeKind) bool { + switch kind { + case GraphNodeSystem, GraphNodeKnowledge, GraphNodeExecution, + GraphNodePolicy, GraphNodeQuality, GraphNodeOperations: + return true + default: + return false } +} - return map[string]interface{}{ - "nodes": nodes, - "edges": edges, +func validGraphEdgeKind(kind GraphEdgeKind) bool { + switch kind { + case GraphEdgeContains, GraphEdgeDependsOn, GraphEdgeReferences, + GraphEdgeProduced, GraphEdgeGovernedBy, GraphEdgeValidatedBy: + return true + default: + return false } } -// SortNodes returns nodes sorted by ID. -func (g *Graph) SortNodes() []*GraphNode { - result := g.GetNodes() - sort.Slice(result, func(i, j int) bool { - return strings.Compare(result[i].ID, result[j].ID) < 0 - }) - return result +func validGraphEventType(eventType GraphEventType) bool { + switch eventType { + case GraphEventCreated, GraphEventUpdated, GraphEventTransitioned, + GraphEventObserved, GraphEventDeleted: + return true + default: + return false + } } diff --git a/internal/graph/graph.go b/internal/graph/graph.go new file mode 100644 index 0000000..0d41a4e --- /dev/null +++ b/internal/graph/graph.go @@ -0,0 +1,278 @@ +// Package graph provides graph traversal and query utilities for the Hawk SDK. +package graph + +import ( + "fmt" + "sort" + "strings" +) + +// GraphNode represents a node in the graph. +type GraphNode struct { + ID string + Type string + Name string + Attrs map[string]string +} + +// GraphEdge represents an edge between two nodes. +type GraphEdge struct { + From string + To string + Weight float64 +} + +// Graph represents a directed graph with nodes and edges. +type Graph struct { + nodes map[string]*GraphNode + edges []GraphEdge +} + +// NewGraph creates a new graph. +func NewGraph() *Graph { + return &Graph{ + nodes: make(map[string]*GraphNode), + } +} + +// AddNode adds a node to the graph. +func (g *Graph) AddNode(node *GraphNode) { + g.nodes[node.ID] = node +} + +// AddEdge adds an edge to the graph. +func (g *Graph) AddEdge(edge GraphEdge) { + g.edges = append(g.edges, edge) +} + +// GetNode retrieves a node by ID. +func (g *Graph) GetNode(id string) (*GraphNode, bool) { + node, ok := g.nodes[id] + return node, ok +} + +// GetNodes returns all nodes. +func (g *Graph) GetNodes() []*GraphNode { + result := make([]*GraphNode, 0, len(g.nodes)) + for _, node := range g.nodes { + result = append(result, node) + } + return result +} + +// GetEdges returns all edges. +func (g *Graph) GetEdges() []GraphEdge { + return g.edges +} + +// BFS performs a breadth-first search from the start node. +func (g *Graph) BFS(start string) ([]string, error) { + if _, ok := g.nodes[start]; !ok { + return nil, fmt.Errorf("node not found: %s", start) + } + + visited := make(map[string]bool) + queue := []string{start} + result := []string{} + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if visited[current] { + continue + } + visited[current] = true + result = append(result, current) + + for _, edge := range g.edges { + if edge.From == current && !visited[edge.To] { + queue = append(queue, edge.To) + } + } + } + + return result, nil +} + +// DFS performs a depth-first search from the start node. +func (g *Graph) DFS(start string) ([]string, error) { + if _, ok := g.nodes[start]; !ok { + return nil, fmt.Errorf("node not found: %s", start) + } + + visited := make(map[string]bool) + result := []string{} + + var dfs func(node string) + dfs = func(node string) { + if visited[node] { + return + } + visited[node] = true + result = append(result, node) + + for _, edge := range g.edges { + if edge.From == node { + dfs(edge.To) + } + } + } + + dfs(start) + return result, nil +} + +// ShortestPath finds the shortest path between two nodes using BFS. +func (g *Graph) ShortestPath(start, end string) ([]string, error) { + if _, ok := g.nodes[start]; !ok { + return nil, fmt.Errorf("start node not found: %s", start) + } + if _, ok := g.nodes[end]; !ok { + return nil, fmt.Errorf("end node not found: %s", end) + } + + visited := make(map[string]bool) + parent := make(map[string]string) + queue := []string{start} + visited[start] = true + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if current == end { + break + } + + for _, edge := range g.edges { + if edge.From == current && !visited[edge.To] { + visited[edge.To] = true + parent[edge.To] = current + queue = append(queue, edge.To) + } + } + } + + if !visited[end] { + return nil, fmt.Errorf("no path from %s to %s", start, end) + } + + // Reconstruct path + path := []string{} + for node := end; node != start; node = parent[node] { + path = append([]string{node}, path...) + } + path = append([]string{start}, path...) + + return path, nil +} + +// PageRank computes PageRank scores for all nodes. +func (g *Graph) PageRank(iterations int, damping float64) map[string]float64 { + if damping == 0 { + damping = 0.85 + } + + n := len(g.nodes) + if n == 0 { + return map[string]float64{} + } + + // Build adjacency list and compute out-degrees + adj := make(map[string][]string) + outDegree := make(map[string]int) + for _, node := range g.nodes { + adj[node.ID] = []string{} + outDegree[node.ID] = 0 + } + + for _, edge := range g.edges { + adj[edge.From] = append(adj[edge.From], edge.To) + outDegree[edge.From]++ + } + + // Initialize PageRank + pr := make(map[string]float64) + for _, node := range g.nodes { + pr[node.ID] = 1.0 / float64(n) + } + + // Iterate + for i := 0; i < iterations; i++ { + newPR := make(map[string]float64) + for _, node := range g.nodes { + newPR[node.ID] = (1 - damping) / float64(n) + } + + for _, node := range g.nodes { + for _, neighbor := range adj[node.ID] { + if outDegree[node.ID] > 0 { + newPR[neighbor] += damping * pr[node.ID] / float64(outDegree[node.ID]) + } + } + } + + pr = newPR + } + + return pr +} + +// FindNodesByType finds all nodes of a specific type. +func (g *Graph) FindNodesByType(nodeType string) []*GraphNode { + result := []*GraphNode{} + for _, node := range g.nodes { + if node.Type == nodeType { + result = append(result, node) + } + } + return result +} + +// FindNodesByAttribute finds all nodes with a specific attribute value. +func (g *Graph) FindNodesByAttribute(key, value string) []*GraphNode { + result := []*GraphNode{} + for _, node := range g.nodes { + if v, ok := node.Attrs[key]; ok && v == value { + result = append(result, node) + } + } + return result +} + +// ToJSON exports the graph as a JSON-compatible map. +func (g *Graph) ToJSON() map[string]interface{} { + nodes := make([]map[string]interface{}, 0, len(g.nodes)) + for _, node := range g.nodes { + nodes = append(nodes, map[string]interface{}{ + "id": node.ID, + "type": node.Type, + "name": node.Name, + "attrs": node.Attrs, + }) + } + + edges := make([]map[string]interface{}, 0, len(g.edges)) + for _, edge := range g.edges { + edges = append(edges, map[string]interface{}{ + "from": edge.From, + "to": edge.To, + "weight": edge.Weight, + }) + } + + return map[string]interface{}{ + "nodes": nodes, + "edges": edges, + } +} + +// SortNodes returns nodes sorted by ID. +func (g *Graph) SortNodes() []*GraphNode { + result := g.GetNodes() + sort.Slice(result, func(i, j int) bool { + return strings.Compare(result[i].ID, result[j].ID) < 0 + }) + return result +} From be1f2553c5d5e6f6ec72626cfbe87b6f84f6aebb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 17:23:55 +0530 Subject: [PATCH 14/15] refactor: copy authoritative OpenAPI spec and update generated code - Update api/openapi.yaml from GrayCodeAI/hawk to match daemon contract - Regenerate Go types from OpenAPI spec - Fix spec test to use Prompt field instead of deprecated Message field - Fix bodyclose linter warning in A2A streaming protocol --- a2a/protocol.go | 1 + api/openapi.yaml | 237 +++++++++++----------- internal/spec/api_gen.gen.go | 369 ++++++++++++++++++++++++++++++++--- internal/spec/spec_test.go | 10 +- 4 files changed, 475 insertions(+), 142 deletions(-) diff --git a/a2a/protocol.go b/a2a/protocol.go index e3cc248..864e4e9 100644 --- a/a2a/protocol.go +++ b/a2a/protocol.go @@ -210,6 +210,7 @@ func (a *A2AProtocol) SendTaskStreaming(ctx context.Context, task *Task) (<-chan req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") + // nolint:bodyclose resp, err := a.client.Do(req) if err != nil { return nil, err diff --git a/api/openapi.yaml b/api/openapi.yaml index 78cd4f9..e8bf615 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -61,39 +61,6 @@ components: agent: type: string description: Named Hawk agent persona to apply. Continuations inherit the persisted persona when omitted. - system_prompt: - type: string - description: Override the agent's system prompt for this request - temperature: - type: number - format: float - minimum: 0 - maximum: 2 - description: Controls response randomness (0.0 = deterministic) - top_p: - type: number - format: float - minimum: 0 - maximum: 1 - description: Nucleus sampling threshold (0.0-1.0) - tools: - type: array - items: - type: object - additionalProperties: true - description: Tool definitions available to the model for this request - tool_results: - type: array - items: - $ref: "#/components/schemas/ToolResult" - description: Results from previous tool calls - tool_choice: - type: string - enum: [none, auto, required] - description: Controls tool selection behavior - parallel_tool_calls: - type: boolean - description: Allow multiple tools to be called in a single response ChatResponse: type: object @@ -112,21 +79,6 @@ components: type: string description: Wall-clock duration, e.g. "1.234s" - ToolResult: - type: object - description: Result of executing a tool call. - required: [tool_use_id, content] - properties: - tool_use_id: - type: string - description: ID of the tool use this result corresponds to - content: - type: string - description: String result from the tool execution - is_error: - type: boolean - description: Whether the tool execution failed - Session: type: object description: Lightweight active record for a durably persisted daemon chat session. @@ -173,23 +125,15 @@ components: tool_calls: type: integer - Message: + GraphScope: type: object properties: - role: + tenant_id: type: string - enum: [user, assistant, tool] - content: + project_id: + type: string + repository_id: type: string - tool_use: {} - tool_results: {} - - GraphScope: - type: object - properties: - tenant_id: {type: string} - project_id: {type: string} - repository_id: {type: string} GraphRef: type: object @@ -198,91 +142,147 @@ components: kind: type: string enum: [system, knowledge, execution, policy, quality, operations] - id: {type: string} + id: + type: string + + GraphArtifactRef: + type: object + required: [uri] + properties: + uri: + type: string + digest: + type: string + media_type: + type: string GraphProvenance: type: object required: [producer] properties: - producer: {type: string} - version: {type: string} - source_id: {type: string} + producer: + type: string + version: + type: string + source_id: + type: string evidence: type: array items: - type: object - required: [uri] - properties: - uri: {type: string} - digest: {type: string} - media_type: {type: string} + $ref: "#/components/schemas/GraphArtifactRef" GraphNode: type: object required: [id, kind, created_at, provenance] properties: - id: {type: string} + id: + type: string kind: type: string enum: [system, knowledge, execution, policy, quality, operations] - scope: {$ref: "#/components/schemas/GraphScope"} - created_at: {type: string, format: date-time} - effective_at: {type: string, format: date-time} - provenance: {$ref: "#/components/schemas/GraphProvenance"} + scope: + $ref: "#/components/schemas/GraphScope" + created_at: + type: string + format: date-time + effective_at: + type: string + format: date-time + provenance: + $ref: "#/components/schemas/GraphProvenance" attributes: type: object - additionalProperties: {type: string} + additionalProperties: + type: string GraphEdge: type: object required: [id, kind, from, to, created_at, provenance] properties: - id: {type: string} + id: + type: string kind: type: string enum: [contains, depends_on, references, produced, governed_by, validated_by] - from: {$ref: "#/components/schemas/GraphRef"} - to: {$ref: "#/components/schemas/GraphRef"} - scope: {$ref: "#/components/schemas/GraphScope"} - created_at: {type: string, format: date-time} - effective_at: {type: string, format: date-time} - provenance: {$ref: "#/components/schemas/GraphProvenance"} + from: + $ref: "#/components/schemas/GraphRef" + to: + $ref: "#/components/schemas/GraphRef" + scope: + $ref: "#/components/schemas/GraphScope" + created_at: + type: string + format: date-time + effective_at: + type: string + format: date-time + provenance: + $ref: "#/components/schemas/GraphProvenance" attributes: type: object - additionalProperties: {type: string} + additionalProperties: + type: string GraphEvent: type: object required: [id, type, subject, occurred_at, provenance] properties: - id: {type: string} + id: + type: string type: type: string enum: [created, updated, transitioned, observed, deleted] - subject: {$ref: "#/components/schemas/GraphRef"} - scope: {$ref: "#/components/schemas/GraphScope"} - occurred_at: {type: string, format: date-time} - correlation_id: {type: string} - causation_id: {type: string} - idempotency_key: {type: string} - provenance: {$ref: "#/components/schemas/GraphProvenance"} + subject: + $ref: "#/components/schemas/GraphRef" + scope: + $ref: "#/components/schemas/GraphScope" + occurred_at: + type: string + format: date-time + correlation_id: + type: string + causation_id: + type: string + idempotency_key: + type: string + provenance: + $ref: "#/components/schemas/GraphProvenance" ExecutionGraph: type: object required: [schema_version, generated_at, scope, nodes, edges, events] properties: - schema_version: {type: string, enum: [hawk.graph/v1]} - generated_at: {type: string, format: date-time} - scope: {$ref: "#/components/schemas/GraphScope"} + schema_version: + type: string + enum: [hawk.graph/v1] + generated_at: + type: string + format: date-time + scope: + $ref: "#/components/schemas/GraphScope" nodes: type: array - items: {$ref: "#/components/schemas/GraphNode"} + items: + $ref: "#/components/schemas/GraphNode" edges: type: array - items: {$ref: "#/components/schemas/GraphEdge"} + items: + $ref: "#/components/schemas/GraphEdge" events: type: array - items: {$ref: "#/components/schemas/GraphEvent"} + items: + $ref: "#/components/schemas/GraphEvent" + + Message: + type: object + properties: + role: + type: string + enum: [user, assistant, tool] + content: + type: string + tool_use: {} + tool_results: {} PaginatedMessages: type: object @@ -612,20 +612,34 @@ paths: get: tags: [graphs] summary: Project a persisted session as a portable execution graph + description: | + Returns Hawk's privacy-safe, read-only `hawk.graph/v1` projection. + Prompt text, tool arguments, tool output, and verification details are + excluded. Explicit Trace checkpoint IDs are additive to authoritative + Trace session correlation performed by Hawk. parameters: - name: id in: path required: true - schema: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._-]+$'} + schema: + type: string + maxLength: 128 + pattern: '^[A-Za-z0-9._-]+$' - name: repository in: query - schema: {type: string, maxLength: 256} + description: Optional repository scope override. + schema: + type: string + maxLength: 256 - name: trace_checkpoint in: query + description: Optional 12-character lowercase hexadecimal Trace checkpoint ID. May be repeated up to 64 times. schema: type: array maxItems: 64 - items: {type: string, pattern: '^[0-9a-f]{12}$'} + items: + type: string + pattern: '^[0-9a-f]{12}$' style: form explode: true responses: @@ -633,27 +647,32 @@ paths: description: Portable execution graph content: application/json: - schema: {$ref: "#/components/schemas/ExecutionGraph"} + schema: + $ref: "#/components/schemas/ExecutionGraph" "400": - description: Invalid graph request + description: Invalid session, repository, or checkpoint input content: application/json: - schema: {$ref: "#/components/schemas/Error"} + schema: + $ref: "#/components/schemas/Error" "401": description: Unauthorized content: application/json: - schema: {$ref: "#/components/schemas/Error"} + schema: + $ref: "#/components/schemas/Error" "404": description: Session not found content: application/json: - schema: {$ref: "#/components/schemas/Error"} + schema: + $ref: "#/components/schemas/Error" "503": - description: Graph projection unavailable + description: Graph projection is not configured content: application/json: - schema: {$ref: "#/components/schemas/Error"} + schema: + $ref: "#/components/schemas/Error" /v1/stats: get: diff --git a/internal/spec/api_gen.gen.go b/internal/spec/api_gen.gen.go index 1513b67..5c6cd5b 100644 --- a/internal/spec/api_gen.gen.go +++ b/internal/spec/api_gen.gen.go @@ -12,6 +12,138 @@ const ( BearerAuthScopes bearerAuthContextKey = "BearerAuth.Scopes" ) +// Defines values for ExecutionGraphSchemaVersion. +const ( + HawkGraphv1 ExecutionGraphSchemaVersion = "hawk.graph/v1" +) + +// Valid indicates whether the value is a known member of the ExecutionGraphSchemaVersion enum. +func (e ExecutionGraphSchemaVersion) Valid() bool { + switch e { + case HawkGraphv1: + return true + default: + return false + } +} + +// Defines values for GraphEdgeKind. +const ( + Contains GraphEdgeKind = "contains" + DependsOn GraphEdgeKind = "depends_on" + GovernedBy GraphEdgeKind = "governed_by" + Produced GraphEdgeKind = "produced" + References GraphEdgeKind = "references" + ValidatedBy GraphEdgeKind = "validated_by" +) + +// Valid indicates whether the value is a known member of the GraphEdgeKind enum. +func (e GraphEdgeKind) Valid() bool { + switch e { + case Contains: + return true + case DependsOn: + return true + case GovernedBy: + return true + case Produced: + return true + case References: + return true + case ValidatedBy: + return true + default: + return false + } +} + +// Defines values for GraphEventType. +const ( + Created GraphEventType = "created" + Deleted GraphEventType = "deleted" + Observed GraphEventType = "observed" + Transitioned GraphEventType = "transitioned" + Updated GraphEventType = "updated" +) + +// Valid indicates whether the value is a known member of the GraphEventType enum. +func (e GraphEventType) Valid() bool { + switch e { + case Created: + return true + case Deleted: + return true + case Observed: + return true + case Transitioned: + return true + case Updated: + return true + default: + return false + } +} + +// Defines values for GraphNodeKind. +const ( + GraphNodeKindExecution GraphNodeKind = "execution" + GraphNodeKindKnowledge GraphNodeKind = "knowledge" + GraphNodeKindOperations GraphNodeKind = "operations" + GraphNodeKindPolicy GraphNodeKind = "policy" + GraphNodeKindQuality GraphNodeKind = "quality" + GraphNodeKindSystem GraphNodeKind = "system" +) + +// Valid indicates whether the value is a known member of the GraphNodeKind enum. +func (e GraphNodeKind) Valid() bool { + switch e { + case GraphNodeKindExecution: + return true + case GraphNodeKindKnowledge: + return true + case GraphNodeKindOperations: + return true + case GraphNodeKindPolicy: + return true + case GraphNodeKindQuality: + return true + case GraphNodeKindSystem: + return true + default: + return false + } +} + +// Defines values for GraphRefKind. +const ( + GraphRefKindExecution GraphRefKind = "execution" + GraphRefKindKnowledge GraphRefKind = "knowledge" + GraphRefKindOperations GraphRefKind = "operations" + GraphRefKindPolicy GraphRefKind = "policy" + GraphRefKindQuality GraphRefKind = "quality" + GraphRefKindSystem GraphRefKind = "system" +) + +// Valid indicates whether the value is a known member of the GraphRefKind enum. +func (e GraphRefKind) Valid() bool { + switch e { + case GraphRefKindExecution: + return true + case GraphRefKindKnowledge: + return true + case GraphRefKindOperations: + return true + case GraphRefKindPolicy: + return true + case GraphRefKindQuality: + return true + case GraphRefKindSystem: + return true + default: + return false + } +} + // Defines values for MessageRole. const ( Assistant MessageRole = "assistant" @@ -35,62 +167,237 @@ func (e MessageRole) Valid() bool { // ChatRequest defines model for ChatRequest. type ChatRequest struct { - // Message The user message to send to the agent - Message string `json:"message"` + // Agent Named Hawk agent persona to apply. Continuations inherit the persisted persona when omitted. + Agent *string `json:"agent,omitempty"` + + // Autonomy Autonomy preset controlling non-interactive permission approval + Autonomy *string `json:"autonomy,omitempty"` + + // Cwd Existing directory associated with session metadata. The daemon executes tools from its startup working directory; it never performs a process-wide chdir per request. + Cwd *string `json:"cwd,omitempty"` + + // MaxTurns Cap on agent turns for this request + MaxTurns *int `json:"max_turns,omitempty"` // Model Optional model override Model *string `json:"model,omitempty"` - // SessionId Continue an existing session (omit to start a new one) - SessionId *string `json:"session_id,omitempty"` + // Prompt The user prompt to send to the agent + Prompt string `json:"prompt"` - // Stream Use SSE streaming for the response - Stream *bool `json:"stream,omitempty"` + // SessionId Durable session ID to continue. The session must already exist; successful continuation returns the same ID. + SessionId *string `json:"session_id,omitempty"` } // ChatResponse defines model for ChatResponse. type ChatResponse struct { - Model *string `json:"model,omitempty"` - Response *string `json:"response,omitempty"` - SessionId *string `json:"session_id,omitempty"` - TokensIn *int `json:"tokens_in,omitempty"` - TokensOut *int `json:"tokens_out,omitempty"` + // Duration Wall-clock duration, e.g. "1.234s" + Duration *string `json:"duration,omitempty"` + Response *string `json:"response,omitempty"` + SessionId *string `json:"session_id,omitempty"` + TokensIn *int `json:"tokens_in,omitempty"` + TokensOut *int `json:"tokens_out,omitempty"` + TurnsTaken *int `json:"turns_taken,omitempty"` } // Error defines model for Error. type Error struct { - Code *string `json:"code,omitempty"` - Error *string `json:"error,omitempty"` + Code *string `json:"code,omitempty"` + Details *string `json:"details,omitempty"` + Error *string `json:"error,omitempty"` +} + +// ExecutionGraph defines model for ExecutionGraph. +type ExecutionGraph struct { + Edges []GraphEdge `json:"edges"` + Events []GraphEvent `json:"events"` + GeneratedAt time.Time `json:"generated_at"` + Nodes []GraphNode `json:"nodes"` + SchemaVersion ExecutionGraphSchemaVersion `json:"schema_version"` + Scope GraphScope `json:"scope"` +} + +// ExecutionGraphSchemaVersion defines model for ExecutionGraph.SchemaVersion. +type ExecutionGraphSchemaVersion string + +// GraphArtifactRef defines model for GraphArtifactRef. +type GraphArtifactRef struct { + Digest *string `json:"digest,omitempty"` + MediaType *string `json:"media_type,omitempty"` + Uri string `json:"uri"` +} + +// GraphEdge defines model for GraphEdge. +type GraphEdge struct { + Attributes *map[string]string `json:"attributes,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt *time.Time `json:"effective_at,omitempty"` + From GraphRef `json:"from"` + Id string `json:"id"` + Kind GraphEdgeKind `json:"kind"` + Provenance GraphProvenance `json:"provenance"` + Scope *GraphScope `json:"scope,omitempty"` + To GraphRef `json:"to"` +} + +// GraphEdgeKind defines model for GraphEdge.Kind. +type GraphEdgeKind string + +// GraphEvent defines model for GraphEvent. +type GraphEvent struct { + CausationId *string `json:"causation_id,omitempty"` + CorrelationId *string `json:"correlation_id,omitempty"` + Id string `json:"id"` + IdempotencyKey *string `json:"idempotency_key,omitempty"` + OccurredAt time.Time `json:"occurred_at"` + Provenance GraphProvenance `json:"provenance"` + Scope *GraphScope `json:"scope,omitempty"` + Subject GraphRef `json:"subject"` + Type GraphEventType `json:"type"` +} + +// GraphEventType defines model for GraphEvent.Type. +type GraphEventType string + +// GraphNode defines model for GraphNode. +type GraphNode struct { + Attributes *map[string]string `json:"attributes,omitempty"` + CreatedAt time.Time `json:"created_at"` + EffectiveAt *time.Time `json:"effective_at,omitempty"` + Id string `json:"id"` + Kind GraphNodeKind `json:"kind"` + Provenance GraphProvenance `json:"provenance"` + Scope *GraphScope `json:"scope,omitempty"` +} + +// GraphNodeKind defines model for GraphNode.Kind. +type GraphNodeKind string + +// GraphProvenance defines model for GraphProvenance. +type GraphProvenance struct { + Evidence *[]GraphArtifactRef `json:"evidence,omitempty"` + Producer string `json:"producer"` + SourceId *string `json:"source_id,omitempty"` + Version *string `json:"version,omitempty"` +} + +// GraphRef defines model for GraphRef. +type GraphRef struct { + Id string `json:"id"` + Kind GraphRefKind `json:"kind"` +} + +// GraphRefKind defines model for GraphRef.Kind. +type GraphRefKind string + +// GraphScope defines model for GraphScope. +type GraphScope struct { + ProjectId *string `json:"project_id,omitempty"` + RepositoryId *string `json:"repository_id,omitempty"` + TenantId *string `json:"tenant_id,omitempty"` +} + +// HealthResponse defines model for HealthResponse. +type HealthResponse struct { + ActiveSessions *int `json:"active_sessions,omitempty"` + StartedAt *string `json:"started_at,omitempty"` + Status *string `json:"status,omitempty"` + Uptime *string `json:"uptime,omitempty"` + Version *string `json:"version,omitempty"` } // Message defines model for Message. type Message struct { - Content *string `json:"content,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - Id *string `json:"id,omitempty"` - Role *MessageRole `json:"role,omitempty"` - SessionId *string `json:"session_id,omitempty"` + Content *string `json:"content,omitempty"` + Role *MessageRole `json:"role,omitempty"` + ToolResults interface{} `json:"tool_results,omitempty"` + ToolUse interface{} `json:"tool_use,omitempty"` } // MessageRole defines model for Message.Role. type MessageRole string -// Session defines model for Session. +// ModelStat defines model for ModelStat. +type ModelStat struct { + CostUsd *float32 `json:"cost_usd,omitempty"` + Model *string `json:"model,omitempty"` + Requests *int `json:"requests,omitempty"` +} + +// PaginatedMessages defines model for PaginatedMessages. +type PaginatedMessages struct { + Data *[]Message `json:"data,omitempty"` + HasMore *bool `json:"has_more,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Total *int `json:"total,omitempty"` +} + +// ReadyResponse defines model for ReadyResponse. +type ReadyResponse struct { + Ready *bool `json:"ready,omitempty"` + + // Reason Why the daemon is not ready (omitted when ready) + Reason *string `json:"reason,omitempty"` + Uptime *string `json:"uptime,omitempty"` +} + +// ReviewRequest defines model for ReviewRequest. +type ReviewRequest struct { + Background *bool `json:"background,omitempty"` + Concerns *string `json:"concerns,omitempty"` + Model *string `json:"model,omitempty"` + + // Sha Hex git SHA (7-40 chars) to review + Sha string `json:"sha"` +} + +// ReviewResponse defines model for ReviewResponse. +type ReviewResponse struct { + Id *int64 `json:"id,omitempty"` + Message *string `json:"message,omitempty"` + Sha *string `json:"sha,omitempty"` + Status *string `json:"status,omitempty"` +} + +// ReviewStatusResponse defines model for ReviewStatusResponse. +type ReviewStatusResponse struct { + // Status Output of `hawk review status` + Status *string `json:"status,omitempty"` +} + +// Session Lightweight active record for a durably persisted daemon chat session. type Session struct { + Agent *string `json:"agent,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Id *string `json:"id,omitempty"` + LastUsed *time.Time `json:"last_used,omitempty"` + Turns *int `json:"turns,omitempty"` +} + +// SessionDetail Persisted session detail loaded from session storage. +type SessionDetail struct { + Agent *string `json:"agent,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` + Cwd *string `json:"cwd,omitempty"` Id *string `json:"id,omitempty"` MessageCount *int `json:"message_count,omitempty"` Model *string `json:"model,omitempty"` + Name *string `json:"name,omitempty"` + Provider *string `json:"provider,omitempty"` + ToolCalls *int `json:"tool_calls,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } // Stats defines model for Stats. type Stats struct { - ActiveSessions *int `json:"active_sessions,omitempty"` - TokensIn *int `json:"tokens_in,omitempty"` - TokensOut *int `json:"tokens_out,omitempty"` - TotalMessages *int `json:"total_messages,omitempty"` - TotalSessions *int `json:"total_sessions,omitempty"` + ActiveDays *int `json:"active_days,omitempty"` + Models *[]ModelStat `json:"models,omitempty"` + TotalCostUsd *float32 `json:"total_cost_usd,omitempty"` + TotalMessages *int `json:"total_messages,omitempty"` + TotalSessions *int `json:"total_sessions,omitempty"` + TotalToolCalls *int `json:"total_tool_calls,omitempty"` } // apiKeyAuthContextKey is the context key for ApiKeyAuth security scheme @@ -99,10 +406,13 @@ type apiKeyAuthContextKey string // bearerAuthContextKey is the context key for BearerAuth security scheme type bearerAuthContextKey string -// GetV1SessionsParams defines parameters for GetV1Sessions. -type GetV1SessionsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +// GetV1SessionsIdGraphParams defines parameters for GetV1SessionsIdGraph. +type GetV1SessionsIdGraphParams struct { + // Repository Optional repository scope override. + Repository *string `form:"repository,omitempty" json:"repository,omitempty"` + + // TraceCheckpoint Optional 12-character lowercase hexadecimal Trace checkpoint ID. May be repeated up to 64 times. + TraceCheckpoint *[]string `form:"trace_checkpoint,omitempty" json:"trace_checkpoint,omitempty"` } // GetV1SessionsIdMessagesParams defines parameters for GetV1SessionsIdMessages. @@ -113,3 +423,6 @@ type GetV1SessionsIdMessagesParams struct { // PostV1ChatJSONRequestBody defines body for PostV1Chat for application/json ContentType. type PostV1ChatJSONRequestBody = ChatRequest + +// PostV1ReviewJSONRequestBody defines body for PostV1Review for application/json ContentType. +type PostV1ReviewJSONRequestBody = ReviewRequest diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go index 7f9d1f2..c15f37c 100644 --- a/internal/spec/spec_test.go +++ b/internal/spec/spec_test.go @@ -8,7 +8,7 @@ import ( func TestSpecTypesCompileAndJSONRoundTrip(t *testing.T) { // Verify ChatRequest JSON round-trip matches OpenAPI schema expectations. req := ChatRequest{ - Message: "hello", + Prompt: "hello", } b, err := json.Marshal(req) if err != nil { @@ -18,8 +18,8 @@ func TestSpecTypesCompileAndJSONRoundTrip(t *testing.T) { if err := json.Unmarshal(b, &got); err != nil { t.Fatalf("Unmarshal ChatRequest: %v", err) } - if got.Message != "hello" { - t.Errorf("Message = %q, want %q", got.Message, "hello") + if got.Prompt != "hello" { + t.Errorf("Prompt = %q, want %q", got.Prompt, "hello") } } @@ -37,8 +37,8 @@ func TestSpecEnumValues(t *testing.T) { func TestSpecTypesNotEmpty(t *testing.T) { var chatReq ChatRequest - if chatReq.Message != "" { - t.Error("zero value ChatRequest.Message should be empty") + if chatReq.Prompt != "" { + t.Error("zero value ChatRequest.Prompt should be empty") } var sess Session if sess.Id != nil { From 14c8acb0e4be0b3cda82f5022fed8151e5f9fd38 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 18:24:03 +0530 Subject: [PATCH 15/15] test: add coverage for graph, a2a, resilience, and spec packages - Add comprehensive tests for internal/graph (BFS, DFS, PageRank, etc.) - Add a2a HTTP client tests using httptest (GetAgentCard, SendTask, etc.) - Add resilience RetryPolicy tests - Expand spec tests for all Graph types and endpoint types - Fix spec test for renamed Message->Prompt field --- a2a/protocol_test.go | 338 +++++++++++++++++++++++++++++++++++ internal/graph/graph_test.go | 269 ++++++++++++++++++++++++++++ internal/spec/spec_test.go | 261 ++++++++++++++++++++++++++- resilience/loop_test.go | 73 ++++++++ 4 files changed, 940 insertions(+), 1 deletion(-) create mode 100644 a2a/protocol_test.go create mode 100644 internal/graph/graph_test.go create mode 100644 resilience/loop_test.go diff --git a/a2a/protocol_test.go b/a2a/protocol_test.go new file mode 100644 index 0000000..8f2f63b --- /dev/null +++ b/a2a/protocol_test.go @@ -0,0 +1,338 @@ +package a2a + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewA2AProtocol(t *testing.T) { + p := NewA2AProtocol("http://localhost:8080") + if p == nil { + t.Fatal("NewA2AProtocol returned nil") + } + if p.baseURL != "http://localhost:8080" { + t.Errorf("baseURL = %q, want %q", p.baseURL, "http://localhost:8080") + } +} + +func TestAgentCardJSONRoundTrip(t *testing.T) { + src := AgentCard{ + Name: "test-agent", + URL: "http://localhost:8080", + Version: "1.0", + Capabilities: &Capabilities{Streaming: true}, + Skills: []Skill{{ID: "s1", Name: "Skill 1"}}, + } + var dst AgentCard + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &dst); err != nil { + t.Fatalf("Unmarshal: %v (json: %s)", err, string(b)) + } + if dst.Name != "test-agent" || dst.Version != "1.0" { + t.Errorf("round-trip failed: %+v", dst) + } + if dst.Capabilities == nil || !dst.Capabilities.Streaming { + t.Error("Capabilities.Streaming should be true") + } + if len(dst.Skills) != 1 || dst.Skills[0].ID != "s1" { + t.Error("Skills round-trip failed") + } +} + +func TestTaskJSONRoundTrip(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + src := Task{ + ID: "t1", + Type: "chat", + Status: &TaskStatus{State: "finished", Timestamp: now}, + } + var dst Task + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &dst); err != nil { + t.Fatalf("Unmarshal: %v (json: %s)", err, string(b)) + } + if dst.ID != "t1" || dst.Type != "chat" { + t.Errorf("round-trip failed: %+v", dst) + } + if dst.Status == nil || dst.Status.State != "finished" { + t.Error("Status round-trip failed") + } +} + +func TestMessageAndPartJSONRoundTrip(t *testing.T) { + src := Message{ + Role: "user", + Parts: []Part{ + {Type: "text", Text: "hello"}, + {Type: "file", File: &File{Name: "f1", MIMEType: "text/plain"}}, + }, + } + var dst Message + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &dst); err != nil { + t.Fatalf("Unmarshal: %v (json: %s)", err, string(b)) + } + if dst.Role != "user" || len(dst.Parts) != 2 { + t.Errorf("round-trip failed: %+v", dst) + } + if dst.Parts[0].Text != "hello" || dst.Parts[1].File == nil || dst.Parts[1].File.Name != "f1" { + t.Error("Parts round-trip failed") + } +} + +func TestArtifactJSONRoundTrip(t *testing.T) { + src := Artifact{ + ID: "a1", + Name: "output", + Parts: []Part{{Type: "text", Text: "result"}}, + } + var dst Artifact + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &dst); err != nil { + t.Fatalf("Unmarshal: %v (json: %s)", err, string(b)) + } + if dst.ID != "a1" || dst.Name != "output" || len(dst.Parts) != 1 { + t.Errorf("round-trip failed: %+v", dst) + } +} + +func TestRequirementJSONRoundTrip(t *testing.T) { + src := Requirement{Name: "auth", Type: "string", Value: "token-123"} + var dst Requirement + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &dst); err != nil { + t.Fatalf("Unmarshal: %v (json: %s)", err, string(b)) + } + if dst.Name != "auth" || dst.Type != "string" { + t.Errorf("round-trip failed: %+v", dst) + } +} + +func TestProviderAndExtensionJSONRoundTrip(t *testing.T) { + src := Provider{URL: "https://provider.com"} + var pDst Provider + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &pDst); err != nil { + t.Fatalf("Unmarshal Provider: %v (json: %s)", err, string(b)) + } + if pDst.URL != "https://provider.com" { + t.Error("Provider round-trip failed") + } + + extSrc := Extension{URI: "ext://custom", Name: "custom-ext", Version: "1.0"} + var extDst Extension + b2, err := json.Marshal(extSrc) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b2, &extDst); err != nil { + t.Fatalf("Unmarshal Extension: %v (json: %s)", err, string(b2)) + } + if extDst.URI != "ext://custom" || extDst.Name != "custom-ext" { + t.Error("Extension round-trip failed") + } +} + +func TestTaskStatusTimestampRoundTrip(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + src := TaskStatus{State: "submitted", Timestamp: now} + var dst TaskStatus + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &dst); err != nil { + t.Fatalf("Unmarshal: %v (json: %s)", err, string(b)) + } + if dst.State != "submitted" || !dst.Timestamp.Equal(now) { + t.Errorf("round-trip failed: state=%s time=%v", dst.State, dst.Timestamp) + } +} + +func TestMetadataJSONRoundTrip(t *testing.T) { + src := Task{ + ID: "t1", + Type: "chat", + Metadata: map[string]interface{}{"key": "value", "count": float64(3)}, + Status: &TaskStatus{State: "working", Timestamp: time.Now()}, + } + var dst Task + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &dst); err != nil { + t.Fatalf("Unmarshal: %v (json: %s)", err, string(b)) + } + if dst.Metadata == nil || dst.Metadata["key"] != "value" { + t.Error("Metadata round-trip failed") + } +} + +func TestGetAgentCard(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/agent.json" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(AgentCard{ + Name: "test-agent", URL: serverURL(r), Version: "1.0", + Capabilities: &Capabilities{Streaming: true}, + }) + })) + defer server.Close() + + proto := NewA2AProtocol(server.URL) + card, err := proto.GetAgentCard(context.Background()) + if err != nil { + t.Fatal(err) + } + if card.Name != "test-agent" || card.Version != "1.0" { + t.Errorf("card mismatch: %+v", card) + } + if cached := proto.AgentCard(); cached == nil || cached.Name != "test-agent" { + t.Error("cached agent card not set") + } +} + +func TestGetAgentCardNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + proto := NewA2AProtocol(server.URL) + _, err := proto.GetAgentCard(context.Background()) + if err == nil { + t.Fatal("expected error for 404") + } +} + +func TestSendTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": Task{ID: "t1", Type: "chat", Status: &TaskStatus{State: "finished", Timestamp: time.Now()}}, + }) + })) + defer server.Close() + + proto := NewA2AProtocol(server.URL) + task := &Task{ID: "t1", Type: "chat"} + result, err := proto.SendTask(context.Background(), task) + if err != nil { + t.Fatal(err) + } + if result.ID != "t1" { + t.Errorf("task ID = %q, want %q", result.ID, "t1") + } +} + +func TestSendTaskError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + proto := NewA2AProtocol(server.URL) + _, err := proto.SendTask(context.Background(), &Task{ID: "t1", Type: "chat"}) + if err == nil { + t.Fatal("expected error for 500") + } +} + +func TestGetTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": Task{ID: "t1", Type: "chat", Status: &TaskStatus{State: "finished", Timestamp: time.Now()}}, + }) + })) + defer server.Close() + + proto := NewA2AProtocol(server.URL) + result, err := proto.GetTask(context.Background(), "t1") + if err != nil { + t.Fatal(err) + } + if result.ID != "t1" { + t.Errorf("task ID = %q, want %q", result.ID, "t1") + } +} + +func TestCancelTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + proto := NewA2AProtocol(server.URL) + err := proto.CancelTask(context.Background(), "t1") + if err != nil { + t.Fatal(err) + } +} + +func TestSendTaskStreaming(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("expected Flusher") + } + enc := json.NewEncoder(w) + enc.Encode(Task{ID: "t1", Type: "chat", Status: &TaskStatus{State: "working", Timestamp: time.Now()}}) + flusher.Flush() + enc.Encode(Task{ID: "t2", Type: "chat", Status: &TaskStatus{State: "finished", Timestamp: time.Now()}}) + flusher.Flush() + })) + defer server.Close() + + proto := NewA2AProtocol(server.URL) + ch, err := proto.SendTaskStreaming(context.Background(), &Task{ID: "t0", Type: "chat"}) + if err != nil { + t.Fatal(err) + } + count := 0 + for range ch { + count++ + } + if count != 2 { + t.Errorf("expected 2 streamed tasks, got %d", count) + } +} + +func TestAgentCardCached(t *testing.T) { + proto := NewA2AProtocol("http://localhost:8080") + card := proto.AgentCard() + if card != nil { + t.Error("expected nil before any GetAgentCard call") + } +} + +func serverURL(r *http.Request) string { + return "http://" + r.Host +} diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go new file mode 100644 index 0000000..84a1966 --- /dev/null +++ b/internal/graph/graph_test.go @@ -0,0 +1,269 @@ +package graph + +import ( + "testing" +) + +func TestNewGraph(t *testing.T) { + g := NewGraph() + if g == nil { + t.Fatal("NewGraph returned nil") + } + if len(g.GetNodes()) != 0 { + t.Error("new graph should have 0 nodes") + } + if len(g.GetEdges()) != 0 { + t.Error("new graph should have 0 edges") + } +} + +func TestAddAndGetNode(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "n1", Type: "system", Name: "Node 1", Attrs: map[string]string{"key": "val"}}) + node, ok := g.GetNode("n1") + if !ok { + t.Fatal("expected node n1 to exist") + } + if node.ID != "n1" || node.Type != "system" || node.Name != "Node 1" { + t.Errorf("node mismatch: %+v", node) + } + if node.Attrs["key"] != "val" { + t.Errorf("attr mismatch: %+v", node.Attrs) + } + _, ok = g.GetNode("nonexistent") + if ok { + t.Error("expected nonexistent node to return false") + } +} + +func TestAddEdge(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "n1"}) + g.AddNode(&GraphNode{ID: "n2"}) + g.AddEdge(GraphEdge{From: "n1", To: "n2", Weight: 1.0}) + edges := g.GetEdges() + if len(edges) != 1 { + t.Fatalf("expected 1 edge, got %d", len(edges)) + } + if edges[0].From != "n1" || edges[0].To != "n2" || edges[0].Weight != 1.0 { + t.Errorf("edge mismatch: %+v", edges[0]) + } +} + +func TestGetNodesAndEdges(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "n1"}) + g.AddNode(&GraphNode{ID: "n2"}) + nodes := g.GetNodes() + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(nodes)) + } +} + +func TestBFS(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "a"}) + g.AddNode(&GraphNode{ID: "b"}) + g.AddNode(&GraphNode{ID: "c"}) + g.AddNode(&GraphNode{ID: "d"}) + g.AddEdge(GraphEdge{From: "a", To: "b"}) + g.AddEdge(GraphEdge{From: "a", To: "c"}) + g.AddEdge(GraphEdge{From: "b", To: "d"}) + + result, err := g.BFS("a") + if err != nil { + t.Fatal(err) + } + if len(result) != 4 { + t.Fatalf("expected 4 nodes, got %d: %v", len(result), result) + } +} + +func TestBFSNodeNotFound(t *testing.T) { + g := NewGraph() + _, err := g.BFS("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent node") + } +} + +func TestDFS(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "a"}) + g.AddNode(&GraphNode{ID: "b"}) + g.AddNode(&GraphNode{ID: "c"}) + g.AddNode(&GraphNode{ID: "d"}) + g.AddEdge(GraphEdge{From: "a", To: "b"}) + g.AddEdge(GraphEdge{From: "a", To: "c"}) + g.AddEdge(GraphEdge{From: "b", To: "d"}) + + result, err := g.DFS("a") + if err != nil { + t.Fatal(err) + } + if len(result) != 4 { + t.Fatalf("expected 4 nodes, got %d: %v", len(result), result) + } +} + +func TestDFSNodeNotFound(t *testing.T) { + g := NewGraph() + _, err := g.DFS("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent node") + } +} + +func TestShortestPath(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "a"}) + g.AddNode(&GraphNode{ID: "b"}) + g.AddNode(&GraphNode{ID: "c"}) + g.AddNode(&GraphNode{ID: "d"}) + g.AddEdge(GraphEdge{From: "a", To: "b"}) + g.AddEdge(GraphEdge{From: "a", To: "c"}) + g.AddEdge(GraphEdge{From: "b", To: "d"}) + g.AddEdge(GraphEdge{From: "c", To: "d"}) + + path, err := g.ShortestPath("a", "d") + if err != nil { + t.Fatal(err) + } + if len(path) < 2 { + t.Fatalf("expected path from a to d, got %v", path) + } + if path[0] != "a" || path[len(path)-1] != "d" { + t.Errorf("path should start with a and end with d: %v", path) + } +} + +func TestShortestPathNodeNotFound(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "a"}) + g.AddNode(&GraphNode{ID: "b"}) + _, err := g.ShortestPath("a", "c") + if err == nil { + t.Fatal("expected error for nonexistent end node") + } + _, err = g.ShortestPath("x", "b") + if err == nil { + t.Fatal("expected error for nonexistent start node") + } +} + +func TestShortestPathNoPath(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "a"}) + g.AddNode(&GraphNode{ID: "b"}) + _, err := g.ShortestPath("a", "b") + if err == nil { + t.Fatal("expected error when no path exists") + } +} + +func TestPageRank(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "a"}) + g.AddNode(&GraphNode{ID: "b"}) + g.AddNode(&GraphNode{ID: "c"}) + g.AddEdge(GraphEdge{From: "a", To: "b"}) + g.AddEdge(GraphEdge{From: "a", To: "c"}) + g.AddEdge(GraphEdge{From: "b", To: "c"}) + + pr := g.PageRank(10, 0.85) + if len(pr) != 3 { + t.Fatalf("expected 3 PageRank scores, got %d", len(pr)) + } + for _, id := range []string{"a", "b", "c"} { + if _, ok := pr[id]; !ok { + t.Errorf("missing PageRank for %s", id) + } + } +} + +func TestPageRankEmptyGraph(t *testing.T) { + g := NewGraph() + pr := g.PageRank(10, 0.85) + if len(pr) != 0 { + t.Errorf("expected empty PageRank for empty graph, got %d", len(pr)) + } +} + +func TestPageRankDefaultDamping(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "a"}) + pr := g.PageRank(1, 0) + if len(pr) != 1 { + t.Fatalf("expected 1 PageRank score, got %d", len(pr)) + } +} + +func TestFindNodesByType(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "n1", Type: "system"}) + g.AddNode(&GraphNode{ID: "n2", Type: "knowledge"}) + g.AddNode(&GraphNode{ID: "n3", Type: "system"}) + + nodes := g.FindNodesByType("system") + if len(nodes) != 2 { + t.Fatalf("expected 2 system nodes, got %d", len(nodes)) + } +} + +func TestFindNodesByAttribute(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "n1", Attrs: map[string]string{"env": "prod"}}) + g.AddNode(&GraphNode{ID: "n2", Attrs: map[string]string{"env": "dev"}}) + g.AddNode(&GraphNode{ID: "n3", Attrs: map[string]string{"env": "prod"}}) + + nodes := g.FindNodesByAttribute("env", "prod") + if len(nodes) != 2 { + t.Fatalf("expected 2 prod nodes, got %d", len(nodes)) + } +} + +func TestFindNodesByAttributeNoMatch(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "n1", Attrs: map[string]string{"env": "prod"}}) + nodes := g.FindNodesByAttribute("env", "staging") + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestToJSON(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "n1", Type: "system", Name: "Node 1"}) + g.AddEdge(GraphEdge{From: "n1", To: "n2", Weight: 1.0}) + + j := g.ToJSON() + if j == nil { + t.Fatal("ToJSON returned nil") + } + nodes, ok := j["nodes"].([]map[string]interface{}) + if !ok || len(nodes) != 1 { + t.Errorf("expected 1 node in JSON, got %d", len(nodes)) + } + edges, ok := j["edges"].([]map[string]interface{}) + if !ok || len(edges) != 1 { + t.Errorf("expected 1 edge in JSON, got %d", len(edges)) + } + if nodes[0]["id"] != "n1" { + t.Errorf("node id mismatch: %v", nodes[0]["id"]) + } +} + +func TestSortNodes(t *testing.T) { + g := NewGraph() + g.AddNode(&GraphNode{ID: "c"}) + g.AddNode(&GraphNode{ID: "a"}) + g.AddNode(&GraphNode{ID: "b"}) + + sorted := g.SortNodes() + if len(sorted) != 3 { + t.Fatalf("expected 3 nodes, got %d", len(sorted)) + } + if sorted[0].ID != "a" || sorted[1].ID != "b" || sorted[2].ID != "c" { + t.Errorf("nodes not sorted: %v", sorted) + } +} diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go index c15f37c..78a50ab 100644 --- a/internal/spec/spec_test.go +++ b/internal/spec/spec_test.go @@ -3,10 +3,10 @@ package spec import ( "encoding/json" "testing" + "time" ) func TestSpecTypesCompileAndJSONRoundTrip(t *testing.T) { - // Verify ChatRequest JSON round-trip matches OpenAPI schema expectations. req := ChatRequest{ Prompt: "hello", } @@ -64,3 +64,262 @@ func TestSpecMessageRoleValid(t *testing.T) { t.Error("bogus role Valid() should be false") } } + +func TestGraphScopeRoundTrip(t *testing.T) { + src := GraphScope{TenantId: strPtr("t1"), ProjectId: strPtr("p1"), RepositoryId: strPtr("r1")} + var dst GraphScope + roundTrip(t, src, &dst) + if *dst.TenantId != "t1" || *dst.ProjectId != "p1" || *dst.RepositoryId != "r1" { + t.Errorf("GraphScope round-trip failed: %+v", dst) + } +} + +func TestGraphRefRoundTrip(t *testing.T) { + src := GraphRef{Id: "n1", Kind: GraphRefKindExecution} + var dst GraphRef + roundTrip(t, src, &dst) + if dst.Id != "n1" || dst.Kind != GraphRefKindExecution { + t.Errorf("GraphRef round-trip failed: %+v", dst) + } +} + +func TestGraphArtifactRefRoundTrip(t *testing.T) { + src := GraphArtifactRef{Uri: "file:///a", Digest: strPtr("abc"), MediaType: strPtr("text/plain")} + var dst GraphArtifactRef + roundTrip(t, src, &dst) + if dst.Uri != "file:///a" || *dst.Digest != "abc" || *dst.MediaType != "text/plain" { + t.Errorf("GraphArtifactRef round-trip failed: %+v", dst) + } +} + +func TestGraphProvenanceRoundTrip(t *testing.T) { + src := GraphProvenance{ + Producer: "test", + Version: strPtr("1.0"), + Evidence: &[]GraphArtifactRef{{Uri: "file:///e", Digest: strPtr("def")}}, + } + var dst GraphProvenance + roundTrip(t, src, &dst) + if dst.Producer != "test" || *dst.Version != "1.0" { + t.Errorf("GraphProvenance round-trip producer failed: %+v", dst) + } + if dst.Evidence == nil || len(*dst.Evidence) != 1 { + t.Fatal("GraphProvenance evidence missing") + } + if (*dst.Evidence)[0].Uri != "file:///e" { + t.Errorf("GraphProvenance evidence URI mismatch") + } +} + +func TestGraphNodeRoundTrip(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + src := GraphNode{ + Id: "n1", + Kind: GraphNodeKindSystem, + CreatedAt: now, + Provenance: GraphProvenance{Producer: "test"}, + } + var dst GraphNode + roundTrip(t, src, &dst) + if dst.Id != "n1" || dst.Kind != GraphNodeKindSystem { + t.Errorf("GraphNode round-trip failed: %+v", dst) + } + if !dst.CreatedAt.Equal(now) { + t.Errorf("GraphNode.CreatedAt = %v, want %v", dst.CreatedAt, now) + } +} + +func TestGraphEdgeRoundTrip(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + src := GraphEdge{ + Id: "e1", + Kind: DependsOn, + From: GraphRef{Id: "n1", Kind: GraphRefKindExecution}, + To: GraphRef{Id: "n2", Kind: GraphRefKindKnowledge}, + CreatedAt: now, + Provenance: GraphProvenance{Producer: "test"}, + Scope: &GraphScope{TenantId: strPtr("t1")}, + } + var dst GraphEdge + roundTrip(t, src, &dst) + if dst.Id != "e1" || dst.Kind != DependsOn { + t.Errorf("GraphEdge round-trip kind failed") + } + if dst.From.Id != "n1" || dst.To.Id != "n2" { + t.Errorf("GraphEdge round-trip refs failed") + } + if dst.Scope == nil || *dst.Scope.TenantId != "t1" { + t.Errorf("GraphEdge round-trip scope failed") + } +} + +func TestGraphEventRoundTrip(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + src := GraphEvent{ + Id: "ev1", + Type: Created, + Subject: GraphRef{Id: "n1", Kind: GraphRefKindSystem}, + OccurredAt: now, + Provenance: GraphProvenance{Producer: "test"}, + } + var dst GraphEvent + roundTrip(t, src, &dst) + if dst.Id != "ev1" || dst.Type != Created { + t.Errorf("GraphEvent round-trip failed: %+v", dst) + } +} + +func TestExecutionGraphRoundTrip(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + src := ExecutionGraph{ + SchemaVersion: HawkGraphv1, + Scope: GraphScope{TenantId: strPtr("t1")}, + GeneratedAt: now, + Nodes: []GraphNode{{ + Id: "n1", Kind: GraphNodeKindSystem, CreatedAt: now, + Provenance: GraphProvenance{Producer: "test"}, + }}, + Edges: []GraphEdge{{ + Id: "e1", Kind: DependsOn, + From: GraphRef{Id: "n1", Kind: GraphRefKindSystem}, + To: GraphRef{Id: "n2", Kind: GraphRefKindKnowledge}, + CreatedAt: now, + Provenance: GraphProvenance{Producer: "test"}, + }}, + Events: []GraphEvent{{ + Id: "ev1", Type: Created, + Subject: GraphRef{Id: "n1", Kind: GraphRefKindSystem}, + OccurredAt: now, + Provenance: GraphProvenance{Producer: "test"}, + }}, + } + var dst ExecutionGraph + roundTrip(t, src, &dst) + if dst.SchemaVersion != HawkGraphv1 { + t.Errorf("ExecutionGraph.SchemaVersion = %v, want %v", dst.SchemaVersion, HawkGraphv1) + } + if len(dst.Nodes) != 1 || len(dst.Edges) != 1 || len(dst.Events) != 1 { + t.Errorf("ExecutionGraph round-trip: nodes=%d edges=%d events=%d", len(dst.Nodes), len(dst.Edges), len(dst.Events)) + } +} + +func TestGraphEnumValid(t *testing.T) { + tests := []struct { + name string + valid bool + check func() bool + }{ + {"HawkGraphv1", true, func() bool { return HawkGraphv1.Valid() }}, + {"ExecutionGraphSchemaVersion(bogus)", false, func() bool { return ExecutionGraphSchemaVersion("x").Valid() }}, + {"DependsOn", true, func() bool { return DependsOn.Valid() }}, + {"GraphEdgeKind(bogus)", false, func() bool { return GraphEdgeKind("x").Valid() }}, + {"Created", true, func() bool { return Created.Valid() }}, + {"GraphEventType(bogus)", false, func() bool { return GraphEventType("x").Valid() }}, + {"GraphNodeKindSystem", true, func() bool { return GraphNodeKindSystem.Valid() }}, + {"GraphNodeKind(bogus)", false, func() bool { return GraphNodeKind("x").Valid() }}, + {"GraphRefKindExecution", true, func() bool { return GraphRefKindExecution.Valid() }}, + {"GraphRefKind(bogus)", false, func() bool { return GraphRefKind("x").Valid() }}, + } + for _, tc := range tests { + got := tc.check() + if got != tc.valid { + t.Errorf("%s.Valid() = %v, want %v", tc.name, got, tc.valid) + } + } +} + +func TestEndpointTypesRoundTrip(t *testing.T) { + // HealthResponse + var hr HealthResponse + roundTrip(t, HealthResponse{Status: strPtr("ok"), Version: strPtr("1.0"), Uptime: strPtr("10s")}, &hr) + if *hr.Status != "ok" || *hr.Version != "1.0" { + t.Errorf("HealthResponse round-trip failed: %+v", hr) + } + + // ReadyResponse + var rr ReadyResponse + roundTrip(t, ReadyResponse{Ready: boolPtr(true)}, &rr) + if *rr.Ready != true { + t.Errorf("ReadyResponse round-trip failed: %+v", rr) + } + + // ChatResponse + var cr ChatResponse + roundTrip(t, ChatResponse{Response: strPtr("hi"), SessionId: strPtr("s1")}, &cr) + if *cr.Response != "hi" || *cr.SessionId != "s1" { + t.Errorf("ChatResponse round-trip failed: %+v", cr) + } + + // Error + var er Error + roundTrip(t, Error{Error: strPtr("err"), Code: strPtr("E1")}, &er) + if *er.Error != "err" || *er.Code != "E1" { + t.Errorf("Error round-trip failed: %+v", er) + } + + // SessionDetail + var sd SessionDetail + roundTrip(t, SessionDetail{Id: strPtr("s1"), Model: strPtr("gpt4")}, &sd) + if *sd.Id != "s1" || *sd.Model != "gpt4" { + t.Errorf("SessionDetail round-trip failed: %+v", sd) + } + + // ModelStat + var ms ModelStat + roundTrip(t, ModelStat{Model: strPtr("gpt4"), CostUsd: float32Ptr(0.5)}, &ms) + if *ms.Model != "gpt4" { + t.Errorf("ModelStat round-trip failed: %+v", ms) + } + + // Stats + var st Stats + roundTrip(t, Stats{TotalSessions: intPtr(10), TotalMessages: intPtr(100)}, &st) + if *st.TotalSessions != 10 || *st.TotalMessages != 100 { + t.Errorf("Stats round-trip failed: %+v", st) + } + + // PaginatedMessages + var pm PaginatedMessages + roundTrip(t, PaginatedMessages{Total: intPtr(5), HasMore: boolPtr(false)}, &pm) + if *pm.Total != 5 || *pm.HasMore != false { + t.Errorf("PaginatedMessages round-trip failed: %+v", pm) + } + + // ReviewRequest + var rvReq ReviewRequest + roundTrip(t, ReviewRequest{Sha: "abc123", Concerns: strPtr("security")}, &rvReq) + if rvReq.Sha != "abc123" || *rvReq.Concerns != "security" { + t.Errorf("ReviewRequest round-trip failed: %+v", rvReq) + } + + // ReviewResponse + var rvResp ReviewResponse + roundTrip(t, ReviewResponse{Id: int64Ptr(1), Status: strPtr("pending")}, &rvResp) + if *rvResp.Id != 1 || *rvResp.Status != "pending" { + t.Errorf("ReviewResponse round-trip failed: %+v", rvResp) + } + + // ReviewStatusResponse + var rvStat ReviewStatusResponse + roundTrip(t, ReviewStatusResponse{Status: strPtr("running")}, &rvStat) + if *rvStat.Status != "running" { + t.Errorf("ReviewStatusResponse round-trip failed: %+v", rvStat) + } +} + +func roundTrip(t *testing.T, src, dst interface{}) { + t.Helper() + b, err := json.Marshal(src) + if err != nil { + t.Fatalf("Marshal(%T): %v", src, err) + } + if err := json.Unmarshal(b, dst); err != nil { + t.Fatalf("Unmarshal(%T): %v (json: %s)", dst, err, string(b)) + } +} + +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } +func intPtr(i int) *int { return &i } +func int64Ptr(i int64) *int64 { return &i } +func float32Ptr(f float32) *float32 { return &f } diff --git a/resilience/loop_test.go b/resilience/loop_test.go new file mode 100644 index 0000000..4d4e515 --- /dev/null +++ b/resilience/loop_test.go @@ -0,0 +1,73 @@ +package resilience + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestExecuteWithRetryImmediateSuccess(t *testing.T) { + policy := RetryPolicy{MaxAttempts: 3, Backoff: time.Millisecond, MaxBackoff: time.Millisecond * 10} + calls := 0 + err := ExecuteWithRetry(context.Background(), policy, func(ctx context.Context) error { + calls++ + return nil + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if calls != 1 { + t.Errorf("expected 1 call, got %d", calls) + } +} + +func TestExecuteWithRetryEventualSuccess(t *testing.T) { + policy := RetryPolicy{MaxAttempts: 5, Backoff: time.Millisecond, MaxBackoff: time.Millisecond * 10} + calls := 0 + err := ExecuteWithRetry(context.Background(), policy, func(ctx context.Context) error { + calls++ + if calls < 3 { + return errors.New("transient error") + } + return nil + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if calls != 3 { + t.Errorf("expected 3 calls, got %d", calls) + } +} + +func TestExecuteWithRetryExhausted(t *testing.T) { + policy := RetryPolicy{MaxAttempts: 3, Backoff: time.Millisecond, MaxBackoff: time.Millisecond * 10} + err := ExecuteWithRetry(context.Background(), policy, func(ctx context.Context) error { + return errors.New("persistent error") + }) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestExecuteWithRetryContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + policy := RetryPolicy{MaxAttempts: 3, Backoff: time.Second, MaxBackoff: time.Second} + err := ExecuteWithRetry(ctx, policy, func(ctx context.Context) error { + return errors.New("should not be reached") + }) + if err == nil { + t.Fatal("expected cancellation error, got nil") + } +} + +func TestExecuteWithRetryBackoffCaps(t *testing.T) { + policy := RetryPolicy{MaxAttempts: 10, Backoff: time.Millisecond, MaxBackoff: time.Millisecond * 5} + err := ExecuteWithRetry(context.Background(), policy, func(ctx context.Context) error { + return errors.New("fail") + }) + if err == nil { + t.Fatal("expected error after exhausting retries") + } +}