diff --git a/README.md b/README.md index d3dcd0d..ddcdec2 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 @@ -106,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 @@ -132,9 +155,11 @@ 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 | +| `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 | @@ -144,11 +169,28 @@ 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 | +| `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 @@ -171,7 +213,7 @@ type RetryConfig struct { InitialBackoff time.Duration MaxBackoff time.Duration BackoffMultiplier float64 - RetryableStatusCodes []int + RetryableStatuses []int } ``` diff --git a/a2a/protocol.go b/a2a/protocol.go new file mode 100644 index 0000000..864e4e9 --- /dev/null +++ b/a2a/protocol.go @@ -0,0 +1,305 @@ +// 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") + + // nolint:bodyclose + 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/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/agent.go b/agent.go index e7deae4..e272521 100644 --- a/agent.go +++ b/agent.go @@ -10,6 +10,18 @@ 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 + + // ToolChoice controls tool selection behavior: "none", "auto", "required". + ToolChoice string + // Tools are the tools available to this agent. Tools []Tool @@ -18,10 +30,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 +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, + 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/api/openapi.yaml b/api/openapi.yaml index 4356670..e8bf615 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -125,6 +125,154 @@ components: tool_calls: type: integer + 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 + + 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 + evidence: + type: array + items: + $ref: "#/components/schemas/GraphArtifactRef" + + 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" + Message: type: object properties: @@ -259,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 @@ -458,6 +608,72 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sessions/{id}/graph: + 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._-]+$' + - name: repository + in: query + 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}$' + style: form + explode: true + responses: + "200": + description: Portable execution graph + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutionGraph" + "400": + description: Invalid session, repository, or checkpoint input + 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 is not configured + 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/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 +} 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/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/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", diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go index 7f9d1f2..78a50ab 100644 --- a/internal/spec/spec_test.go +++ b/internal/spec/spec_test.go @@ -3,12 +3,12 @@ package spec import ( "encoding/json" "testing" + "time" ) 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 { @@ -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.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) +} 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") + } +} 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/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() +} diff --git a/types.go b/types.go index 1ecd2d9..f128766 100644 --- a/types.go +++ b/types.go @@ -4,13 +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"` + 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. @@ -39,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}. @@ -48,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"`