diff --git a/CHANGELOG.md b/CHANGELOG.md index debf25a..9e11d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.6] — 2026-07-16 + +### Added + +- `agent/` package: typed subagent spawn contracts (`SpawnRequest`, + `SpawnResult`, capability/isolation/subagent-type parse helpers, + `Normalize`/`Validate`) and canonical hook event names with vendor + aliases (`CanonicalHookEvent`). Year 0 Grok behavioral port (PACK-01). +- `proto/hawk/contracts/v1/agent.proto` mirroring `SpawnRequest` / + `SpawnResult` for buf lint/breaking and cross-language codegen. + +## [0.1.5] — 2026-07-11 + ### Added - `proto/` — a Buf-managed protobuf schema mirroring every exported Go type, diff --git a/README.md b/README.md index 3827637..3bdf7b5 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ go get github.com/GrayCodeAI/hawk-core-contracts | Package | Key Types | Purpose | |---------|-----------|---------| | `types/` | `Severity`, `Finding`, `FindingSlice`, `ParseSeverity` | Severity levels, findings, shared result vocabulary | -| `tools/` | `ToolCall`, `ToolResult` | Provider-neutral tool call and result contracts | +| `tools/` | `ToolCall`, `ToolResult`, `ToolMeta`, `FinalizeResult`, version lifecycle types | Provider-neutral tool call, result, identity, and finalization contracts | | `events/` | `ToolEvent`, `TraceEvent`, `UsageInfo` | Normalized tool and trace event contracts | | `policy/` | `Risk`, `PermissionVerdict`, `Allow`, `Deny` | Risk, permission verdict, guardian decision, approval request contracts | | `review/` | `Result`, `Finding`, `Comment` | Neutral review findings, comments, stats, and result contracts | | `verify/` | `Report`, `Finding` | Neutral verification findings, stats, and report contracts | | `sessions/` | `Phase`, `CostAccumulator`, `ParsePhase` | Cross-repo agent session state types | +| `agent/` | `SpawnRequest`, `SpawnResult`, hook event names | Typed subagent spawn + hook vocabulary | ## Architecture @@ -36,7 +37,8 @@ hawk-core-contracts (stdlib only) ├── policy/ Risk, PermissionVerdict — governance contracts ├── review/ Result, Finding, Comment — review result contracts ├── verify/ Report, Finding — verification report contracts -└── sessions/ Phase, CostAccumulator — session state contracts +├── sessions/ Phase, CostAccumulator — session state contracts +└── agent/ SpawnRequest, SpawnResult, hook events — subagent spawn contracts ``` ## Scope @@ -76,6 +78,10 @@ complete: 3. Tool, event, and policy contracts added 4. Review and verification result contracts added 5. Sessions package added for cross-repo session state +6. Tool contract versioning & finalization: `ToolMeta` identity envelope, additive-only + version bump rule, closed `ToolNamespace` enum, `BehaviorPreset` back-compat presets, + and the single-locking `FinalizeToolConfig` lifecycle (with `FinalizeResult`, + `VersionWarning`, `FinalizeConfigViolation`) ## Ecosystem @@ -96,6 +102,28 @@ hawk-core-contracts is a **foundation repo** in the hawk-eco mono-ecosystem: `hawk` and all engines import `hawk-core-contracts` when they share a real cross-repo contract; the repo itself never imports back. +## Tool Contract Versioning & Finalization + +Tool configuration has a single locking **finalize** step instead of a scattered set of +enable/disable/options calls that can leave the tool set in an inconsistent state. + +**`ToolMeta`** is the canonical identity envelope attached to every tool-call event. Its +`version` string is **additive-only**: new additive fields do not change it; only breaking +changes (field removal, type change, reserved-range change) bump it. `namespace` is a +**closed enum** (`ToolNamespace`) — a new unknown namespace intentionally fails strict typed +deserialization (`ToolNamespaceFrom` returns an error), so a forward-rolled contract can't +silently mis-route tools to a harness that doesn't understand them. `label` is a +cross-harness grouping key; `read_only` marks tools the harness must treat as side-effect free. + +**`FinalizeToolConfig`** is the single, locking RPC that atomically commits a +`behavior_version` (a `BehaviorPreset`: `current`, `legacy`, or unspecified) plus an +enabled-tool set, and returns any `VersionWarning`s (non-fatal drift) or +`FinalizeConfigViolation`s (fatal, deterministic failures). After a call where +`FinalizeResult.Ok()` returns true (`Finalized && no violations`), tools are safe to call. + +This models the lifecycle in SpaceXAI `grok`, but starts clean — the old scattered +Enable/Disable/Get/Set-ToolOptions surface is intentionally absent. + ## Ecosystem Boundaries Rules that keep this repo at the foundation layer: diff --git a/VERSION b/VERSION index 9faa1b7..c946ee6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/agent/hooks.go b/agent/hooks.go new file mode 100644 index 0000000..1bb3046 --- /dev/null +++ b/agent/hooks.go @@ -0,0 +1,68 @@ +package agent + +import "strings" + +// Hook event names for lifecycle and tool gates. +// +// Hawk may accept vendor aliases (Claude/Cursor); normalize to these +// canonical names at the boundary. Constants are shared so hawk, plugins, +// and SDKs agree on wire/event vocabulary without importing engines. +const ( + HookPreToolUse = "PreToolUse" + HookPostToolUse = "PostToolUse" + HookUserPromptSubmit = "UserPromptSubmit" + HookSessionStart = "SessionStart" + HookSessionEnd = "SessionEnd" + HookStop = "Stop" + HookSubagentStart = "SubagentStart" + HookSubagentStop = "SubagentStop" + HookNotification = "Notification" + HookPermissionRequest = "PermissionRequest" + HookPreCompact = "PreCompact" + HookFailure = "Failure" +) + +// VendorHookAliases maps common third-party hook names to canonical Hawk names. +// Keys are lower-case for case-insensitive lookup. +var VendorHookAliases = map[string]string{ + "pretooluse": HookPreToolUse, + "pre_tool_use": HookPreToolUse, + "posttooluse": HookPostToolUse, + "post_tool_use": HookPostToolUse, + "userpromptsubmit": HookUserPromptSubmit, + "user_prompt_submit": HookUserPromptSubmit, + "sessionstart": HookSessionStart, + "session_start": HookSessionStart, + "sessionend": HookSessionEnd, + "session_end": HookSessionEnd, + "stop": HookStop, + "subagentstart": HookSubagentStart, + "subagent_start": HookSubagentStart, + "subagentstop": HookSubagentStop, + "subagent_stop": HookSubagentStop, + "notification": HookNotification, + "permissionrequest": HookPermissionRequest, + "permission_request": HookPermissionRequest, + "precompact": HookPreCompact, + "pre_compact": HookPreCompact, + "failure": HookFailure, + "onerror": HookFailure, + "on_error": HookFailure, +} + +// CanonicalHookEvent returns the Hawk canonical event name for s, or "" if unknown. +func CanonicalHookEvent(s string) string { + if s == "" { + return "" + } + switch s { + case HookPreToolUse, HookPostToolUse, HookUserPromptSubmit, HookSessionStart, + HookSessionEnd, HookStop, HookSubagentStart, HookSubagentStop, + HookNotification, HookPermissionRequest, HookPreCompact, HookFailure: + return s + } + if c, ok := VendorHookAliases[strings.ToLower(strings.TrimSpace(s))]; ok { + return c + } + return "" +} diff --git a/agent/hooks_test.go b/agent/hooks_test.go new file mode 100644 index 0000000..22f588d --- /dev/null +++ b/agent/hooks_test.go @@ -0,0 +1,22 @@ +package agent + +import "testing" + +func TestCanonicalHookEvent(t *testing.T) { + cases := []struct { + in, want string + }{ + {HookPreToolUse, HookPreToolUse}, + {"pre_tool_use", HookPreToolUse}, + {"PreToolUse", HookPreToolUse}, + {"subagent_start", HookSubagentStart}, + {"on_error", HookFailure}, + {"", ""}, + {"not-a-hook", ""}, + } + for _, tc := range cases { + if got := CanonicalHookEvent(tc.in); got != tc.want { + t.Errorf("CanonicalHookEvent(%q)=%q want %q", tc.in, got, tc.want) + } + } +} diff --git a/agent/spawn.go b/agent/spawn.go new file mode 100644 index 0000000..b9a10dd --- /dev/null +++ b/agent/spawn.go @@ -0,0 +1,238 @@ +// Package agent defines shared DTOs for typed subagent spawn across hawk-eco. +// +// Stdlib only. No engine, CLI, or storage imports. +package agent + +import ( + "fmt" + "strings" +) + +// CapabilityMode limits what tools a subagent may use. +type CapabilityMode string + +const ( + CapReadOnly CapabilityMode = "read-only" + CapReadWrite CapabilityMode = "read-write" + CapExecute CapabilityMode = "execute" + CapAll CapabilityMode = "all" +) + +// IsolationMode selects filesystem isolation for a subagent. +type IsolationMode string + +const ( + IsoNone IsolationMode = "none" + IsoWorktree IsolationMode = "worktree" +) + +// SubagentType selects the built-in subagent profile. +type SubagentType string + +const ( + TypeGeneralPurpose SubagentType = "general-purpose" + TypeExplore SubagentType = "explore" + TypePlan SubagentType = "plan" +) + +// Thoroughness levels for explore subagents. +const ( + ThoroughnessQuick = "quick" + ThoroughnessMedium = "medium" + ThoroughnessVeryThorough = "very-thorough" +) + +// Spawn status values for SpawnResult.Status. +const ( + StatusRunning = "running" + StatusCompleted = "completed" + StatusFailed = "failed" +) + +// SpawnRequest is the cross-repo contract for spawning a subagent. +type SpawnRequest struct { + Prompt string `json:"prompt"` + Description string `json:"description,omitempty"` + SubagentType string `json:"subagent_type,omitempty"` + CapabilityMode string `json:"capability_mode,omitempty"` + Isolation string `json:"isolation,omitempty"` + ResumeFrom string `json:"resume_from,omitempty"` + CWD string `json:"cwd,omitempty"` + Model string `json:"model,omitempty"` + Background bool `json:"background,omitempty"` + Thoroughness string `json:"thoroughness,omitempty"` + ParentSession string `json:"parent_session,omitempty"` +} + +// SpawnResult is the cross-repo contract returned after spawn completes or is accepted. +type SpawnResult struct { + SubagentID string `json:"subagent_id,omitempty"` + SubagentType string `json:"subagent_type,omitempty"` + Status string `json:"status,omitempty"` + Output string `json:"output,omitempty"` + Summary string `json:"summary,omitempty"` + ToolCalls int `json:"tool_calls,omitempty"` + Turns int `json:"turns,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + Persona string `json:"persona,omitempty"` + Error string `json:"error,omitempty"` +} + +// ParseSubagentType normalizes aliases (e.g. "general" → general-purpose). +// Empty input defaults to explore (conservative read-oriented default). +func ParseSubagentType(s string) (SubagentType, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "explore": + return TypeExplore, nil + case "plan": + return TypePlan, nil + case "general", "general-purpose", "general_purpose", "generalpurpose": + return TypeGeneralPurpose, nil + default: + return "", fmt.Errorf("agent: unknown subagent_type %q", s) + } +} + +// ParseCapabilityMode normalizes capability aliases. +// Empty input returns CapReadOnly when defaultFromType is empty; callers +// should prefer DefaultCapabilityForType. +func ParseCapabilityMode(s string) (CapabilityMode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "read-only", "readonly", "read_only", "ro": + return CapReadOnly, nil + case "read-write", "readwrite", "read_write", "rw": + return CapReadWrite, nil + case "execute", "exec": + return CapExecute, nil + case "all", "full": + return CapAll, nil + default: + return "", fmt.Errorf("agent: unknown capability_mode %q", s) + } +} + +// ParseIsolationMode normalizes isolation aliases. Empty → none. +func ParseIsolationMode(s string) (IsolationMode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "none", "off", "false": + return IsoNone, nil + case "worktree", "wt", "git-worktree": + return IsoWorktree, nil + default: + return "", fmt.Errorf("agent: unknown isolation %q", s) + } +} + +// ParseThoroughness normalizes explore thoroughness. Empty → medium. +func ParseThoroughness(s string) (string, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "medium", "med", "default": + return ThoroughnessMedium, nil + case "quick", "fast": + return ThoroughnessQuick, nil + case "very-thorough", "very_thorough", "verythorough", "deep": + return ThoroughnessVeryThorough, nil + default: + return "", fmt.Errorf("agent: unknown thoroughness %q", s) + } +} + +// DefaultCapabilityForType returns the capability implied by a subagent type +// when the request does not set capability_mode. +func DefaultCapabilityForType(t SubagentType) CapabilityMode { + switch t { + case TypeGeneralPurpose: + return CapAll + case TypePlan, TypeExplore: + return CapReadOnly + default: + return CapReadOnly + } +} + +// Normalized is a validated, alias-resolved spawn request. +type Normalized struct { + Prompt string + Description string + SubagentType SubagentType + CapabilityMode CapabilityMode + Isolation IsolationMode + ResumeFrom string + CWD string + Model string + Background bool + Thoroughness string + ParentSession string +} + +// Normalize validates and resolves aliases on r. +// +// Rules: +// - prompt is required unless resume_from is set +// - cwd and isolation=worktree are mutually exclusive +// - thoroughness only applies to explore (ignored otherwise after parse) +// - empty capability_mode uses DefaultCapabilityForType +func (r SpawnRequest) Normalize() (Normalized, error) { + prompt := strings.TrimSpace(r.Prompt) + resume := strings.TrimSpace(r.ResumeFrom) + if prompt == "" && resume == "" { + return Normalized{}, fmt.Errorf("agent: prompt is required unless resume_from is set") + } + + st, err := ParseSubagentType(r.SubagentType) + if err != nil { + return Normalized{}, err + } + + var capMode CapabilityMode + if strings.TrimSpace(r.CapabilityMode) == "" { + capMode = DefaultCapabilityForType(st) + } else { + capMode, err = ParseCapabilityMode(r.CapabilityMode) + if err != nil { + return Normalized{}, err + } + } + + iso, err := ParseIsolationMode(r.Isolation) + if err != nil { + return Normalized{}, err + } + + cwd := strings.TrimSpace(r.CWD) + if cwd != "" && iso == IsoWorktree { + return Normalized{}, fmt.Errorf("agent: cwd and isolation=worktree are mutually exclusive") + } + + thorough := ThoroughnessMedium + if st == TypeExplore { + thorough, err = ParseThoroughness(r.Thoroughness) + if err != nil { + return Normalized{}, err + } + } else if strings.TrimSpace(r.Thoroughness) != "" { + // Explicit thoroughness on non-explore is an error to catch model mistakes. + return Normalized{}, fmt.Errorf("agent: thoroughness is only valid for explore subagents") + } + + return Normalized{ + Prompt: prompt, + Description: strings.TrimSpace(r.Description), + SubagentType: st, + CapabilityMode: capMode, + Isolation: iso, + ResumeFrom: resume, + CWD: cwd, + Model: strings.TrimSpace(r.Model), + Background: r.Background, + Thoroughness: thorough, + ParentSession: strings.TrimSpace(r.ParentSession), + }, nil +} + +// Validate is an alias for Normalize when only the error is needed. +func (r SpawnRequest) Validate() error { + _, err := r.Normalize() + return err +} diff --git a/agent/spawn_test.go b/agent/spawn_test.go new file mode 100644 index 0000000..1c7eb43 --- /dev/null +++ b/agent/spawn_test.go @@ -0,0 +1,135 @@ +package agent + +import ( + "strings" + "testing" +) + +func TestParseSubagentTypeAliases(t *testing.T) { + cases := []struct { + in string + want SubagentType + }{ + {"", TypeExplore}, + {"explore", TypeExplore}, + {"plan", TypePlan}, + {"general", TypeGeneralPurpose}, + {"general-purpose", TypeGeneralPurpose}, + {"General_Purpose", TypeGeneralPurpose}, + } + for _, tc := range cases { + got, err := ParseSubagentType(tc.in) + if err != nil { + t.Fatalf("ParseSubagentType(%q): %v", tc.in, err) + } + if got != tc.want { + t.Errorf("ParseSubagentType(%q)=%q want %q", tc.in, got, tc.want) + } + } + if _, err := ParseSubagentType("wizard"); err == nil { + t.Fatal("expected error for unknown type") + } +} + +func TestParseCapabilityAndIsolation(t *testing.T) { + cap, err := ParseCapabilityMode("ReadOnly") + if err != nil || cap != CapReadOnly { + t.Fatalf("cap: %v %v", cap, err) + } + cap, err = ParseCapabilityMode("all") + if err != nil || cap != CapAll { + t.Fatalf("cap all: %v %v", cap, err) + } + iso, err := ParseIsolationMode("") + if err != nil || iso != IsoNone { + t.Fatalf("iso empty: %v %v", iso, err) + } + iso, err = ParseIsolationMode("worktree") + if err != nil || iso != IsoWorktree { + t.Fatalf("iso wt: %v %v", iso, err) + } + if _, err := ParseIsolationMode("container"); err == nil { + t.Fatal("expected isolation error") + } +} + +func TestNormalizeDefaults(t *testing.T) { + n, err := (SpawnRequest{Prompt: "find TODOs"}).Normalize() + if err != nil { + t.Fatal(err) + } + if n.SubagentType != TypeExplore { + t.Errorf("type=%q", n.SubagentType) + } + if n.CapabilityMode != CapReadOnly { + t.Errorf("cap=%q", n.CapabilityMode) + } + if n.Isolation != IsoNone { + t.Errorf("iso=%q", n.Isolation) + } + if n.Thoroughness != ThoroughnessMedium { + t.Errorf("thorough=%q", n.Thoroughness) + } +} + +func TestNormalizeGeneralDefaultsAll(t *testing.T) { + n, err := (SpawnRequest{Prompt: "implement X", SubagentType: "general"}).Normalize() + if err != nil { + t.Fatal(err) + } + if n.SubagentType != TypeGeneralPurpose || n.CapabilityMode != CapAll { + t.Fatalf("got type=%q cap=%q", n.SubagentType, n.CapabilityMode) + } +} + +func TestNormalizeMutualExclusionCWDWorktree(t *testing.T) { + err := (SpawnRequest{ + Prompt: "x", + Isolation: "worktree", + CWD: "/tmp/other", + }).Validate() + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("want mutual exclusion error, got %v", err) + } +} + +func TestNormalizeResumeWithoutPrompt(t *testing.T) { + n, err := (SpawnRequest{ResumeFrom: "sub-abc"}).Normalize() + if err != nil { + t.Fatal(err) + } + if n.ResumeFrom != "sub-abc" { + t.Fatalf("resume=%q", n.ResumeFrom) + } +} + +func TestNormalizeRequiresPromptOrResume(t *testing.T) { + if err := (SpawnRequest{}).Validate(); err == nil { + t.Fatal("expected error") + } +} + +func TestNormalizeThoroughnessOnlyExplore(t *testing.T) { + err := (SpawnRequest{ + Prompt: "x", + SubagentType: "plan", + Thoroughness: "quick", + }).Validate() + if err == nil || !strings.Contains(err.Error(), "thoroughness") { + t.Fatalf("want thoroughness error, got %v", err) + } +} + +func TestNormalizeExploreThoroughness(t *testing.T) { + n, err := (SpawnRequest{ + Prompt: "x", + SubagentType: "explore", + Thoroughness: "very-thorough", + }).Normalize() + if err != nil { + t.Fatal(err) + } + if n.Thoroughness != ThoroughnessVeryThorough { + t.Fatalf("got %q", n.Thoroughness) + } +} diff --git a/proto/hawk/contracts/v1/agent.proto b/proto/hawk/contracts/v1/agent.proto new file mode 100644 index 0000000..c277637 --- /dev/null +++ b/proto/hawk/contracts/v1/agent.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package hawk.contracts.v1; + +option go_package = "github.com/GrayCodeAI/hawk-core-contracts/gen/go/hawk/contracts/v1;contractsv1"; + +// SpawnRequest mirrors agent.SpawnRequest (agent/spawn.go). +// Canonical wire vocabulary for typed subagent spawn across hawk-eco. +message SpawnRequest { + string prompt = 1; + string description = 2; + string subagent_type = 3; + string capability_mode = 4; + string isolation = 5; + string resume_from = 6; + string cwd = 7; + string model = 8; + bool background = 9; + string thoroughness = 10; + string parent_session = 11; +} + +// SpawnResult mirrors agent.SpawnResult (agent/spawn.go). +message SpawnResult { + string subagent_id = 1; + string subagent_type = 2; + string status = 3; + string output = 4; + string summary = 5; + int32 tool_calls = 6; + int32 turns = 7; + int64 duration_ms = 8; + string worktree_path = 9; + string persona = 10; + string error = 11; +} diff --git a/proto/hawk/contracts/v1/tool.proto b/proto/hawk/contracts/v1/tool.proto index e3e7f63..83c4c7d 100644 --- a/proto/hawk/contracts/v1/tool.proto +++ b/proto/hawk/contracts/v1/tool.proto @@ -6,6 +6,15 @@ import "google/protobuf/struct.proto"; option go_package = "github.com/GrayCodeAI/hawk-core-contracts/gen/go/hawk/contracts/v1;contractsv1"; +// Additive-only version-bump rule for ToolMeta.version: +// +// Additive field/message additions (new fields, new enum values, new messages) +// do NOT bump ToolMeta.version — only breaking changes (field removal, type +// change, field-number reuse, reserved-range changes) do. The hand-written Go +// types in tools/versioning.go are the source of truth for the three Go +// consumers (sight, inspect, hawk); the .proto is authoritative for schema-level +// breaking-change detection and cross-language codegen. Keep the two in sync. + // ToolCall mirrors tools.ToolCall (tools/tool.go) — a provider-neutral // tool invocation contract. arguments is arbitrary JSON in Go // (map[string]interface{}); google.protobuf.Struct is the standard proto @@ -27,3 +36,90 @@ message ToolResult { string content = 2; bool is_error = 3; } + +// ToolNamespace is a CLOSED enum identifying the harness that owns a tool. A +// new namespace is a wire-breaking change: it intentionally fails strict typed +// deserialization on consumers that don't yet know about it (forward-safety — a +// deploy that rolls the contract forward before the consumer code cannot +// silently mis-route a tool it doesn't understand). Enum value 6 (ACP) is +// reserved for the forthcoming hawk-acp repo. +enum ToolNamespace { + TOOL_NAMESPACE_UNSPECIFIED = 0; + TOOL_NAMESPACE_HAWK_BUILD = 1; + TOOL_NAMESPACE_HAWK_BUILD_CONCISE = 2; + TOOL_NAMESPACE_CODEX = 3; + TOOL_NAMESPACE_OPENCODE = 4; + TOOL_NAMESPACE_MCP = 5; + TOOL_NAMESPACE_ACP = 6; +} + +// ToolMeta is the canonical identity envelope attached to tool-call events. +// version is an additive-only string bump (see file-level doc comment): new +// additive fields do NOT change it. namespace is closed (see ToolNamespace); +// label is a cross-harness grouping key; read_only marks tools the harness must +// treat as side-effect free. +message ToolMeta { + string version = 1; + string name = 2; + string kind = 3; + ToolNamespace namespace = 4; + string label = 5; + bool read_only = 6; +} + +// FinalizeErrorCode classifies why a FinalizeToolConfig call reported a +// violation or warning. +enum FinalizeErrorCode { + FINALIZE_ERROR_CODE_UNSPECIFIED = 0; + FINALIZE_ERROR_CODE_UNKNOWN_TOOL = 1; + FINALIZE_ERROR_CODE_INCOMPATIBLE_VERSION = 2; + FINALIZE_ERROR_CODE_DEPRECATED_REPLACED = 3; + FINALIZE_ERROR_CODE_INVALID_PARAMS = 4; +} + +// VersionWarning surfaces non-fatal, behavior-version drift during finalize. +message VersionWarning { + FinalizeErrorCode code = 1; + string message = 2; + repeated string affected_tools = 3; +} + +// FinalizeConfigViolation reports a fatal, deterministic problem that keeps a +// finalize call from succeeding. +message FinalizeConfigViolation { + FinalizeErrorCode code = 1; + string tool_id = 2; + string message = 3; +} + +// FinalizeToolConfigRequest is the single, locking call that atomically +// resolves the tool configuration for a server: pick a behavior_version and an +// enabled tool set, and the server resolves both together so partial, +// interleaved enable/disable calls can never leave an inconsistent state. +message FinalizeToolConfigRequest { + string behavior_version = 1; + repeated string enabled_tools = 2; +} + +// FinalizeToolConfigResponse carries the resolved behavior_version plus any +// warnings or violations. violations non-empty means the call did NOT succeed +// and tools are NOT safe to call; finalized == true with no violations means +// the lock is held and tools may be invoked. +message FinalizeToolConfigResponse { + string behavior_version = 1; + repeated VersionWarning warnings = 2; + repeated FinalizeConfigViolation violations = 3; + bool finalized = 4; +} + +// HawkToolsService is the tool lifecycle RPC surface for hawk. A single, +// locking FinalizeToolConfig call replaces the scattered enable/disable/options +// RPCs found in older harnesses (SpaceXAI grok's Enable/Disable/Get/Set +// ToolOptions, Reminders, Truncation, SystemPrompt, AgentInfo, CompletionState): +// hawk starts clean with just the one call, which atomically commits a +// behavior_version + enabled-tool set and reports any violations or +// back-compat warnings. After a successful FinalizeToolConfig call, tools are +// safe to call. +service HawkToolsService { + rpc FinalizeToolConfig(FinalizeToolConfigRequest) returns (FinalizeToolConfigResponse); +} diff --git a/tools/tools_versioning_test.go b/tools/tools_versioning_test.go new file mode 100644 index 0000000..e91a930 --- /dev/null +++ b/tools/tools_versioning_test.go @@ -0,0 +1,113 @@ +package tools_test + +import ( + "testing" + + "github.com/GrayCodeAI/hawk-core-contracts/tools" +) + +func TestBehaviorPresetParse(t *testing.T) { + valid := map[string]tools.BehaviorPreset{ + "current": tools.BehaviorPresetCurrent, + "legacy": tools.BehaviorPresetLegacy, + "": tools.BehaviorPresetUnspecified, + } + for in, want := range valid { + got, err := tools.BehaviorPresetFrom(in) + if err != nil { + t.Fatalf("BehaviorPresetFrom(%q) error = %v", in, err) + } + if got != want { + t.Fatalf("BehaviorPresetFrom(%q) = %q, want %q", in, got, want) + } + } + + invalid := []string{"future", "CURRENT", "Current", "legacy-v2"} + for _, in := range invalid { + got, err := tools.BehaviorPresetFrom(in) + if err == nil { + t.Fatalf("BehaviorPresetFrom(%q) = %q, want error", in, got) + } + if got != tools.BehaviorPresetUnspecified { + t.Fatalf("BehaviorPresetFrom(%q) zero value = %q, want unspecified", in, got) + } + } +} + +func TestFinalizeResultOk(t *testing.T) { + cases := []struct { + name string + in tools.FinalizeResult + want bool + }{ + {"not finalized", tools.FinalizeResult{Finalized: false}, false}, + {"finalized with violation", tools.FinalizeResult{ + Finalized: true, + Violations: []tools.FinalizeConfigViolation{{Code: tools.FinalizeErrorCodeUnknownTool, ToolID: "x"}}, + }, false}, + {"finalized with warning only", tools.FinalizeResult{ + Finalized: true, + Warnings: []tools.VersionWarning{{Code: tools.FinalizeErrorCodeDeprecatedReplaced}}, + }, true}, + {"finalized clean", tools.FinalizeResult{Finalized: true, BehaviorVersion: "current"}, true}, + } + for _, tc := range cases { + if got := tc.in.Ok(); got != tc.want { + t.Fatalf("%s: Ok() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestToolNamespaceFrom_ClosedEnum(t *testing.T) { + known := []string{"", "hawk_build", "hawk_build_concise", "codex", "opencode", "mcp", "acp"} + for _, in := range known { + got, err := tools.ToolNamespaceFrom(in) + if err != nil { + t.Fatalf("ToolNamespaceFrom(%q) error = %v", in, err) + } + if string(got) != in { + t.Fatalf("ToolNamespaceFrom(%q) = %q", in, got) + } + } + + // Forward-safety: an unknown namespace must error, not silently map. + for _, in := range []string{"future_ns", "grok_build", "HAWK_BUILD", "Mcp"} { + got, err := tools.ToolNamespaceFrom(in) + if err == nil { + t.Fatalf("ToolNamespaceFrom(%q) = %q, want error (closed enum)", in, got) + } + if got != tools.ToolNamespaceUnspecified { + t.Fatalf("ToolNamespaceFrom(%q) zero value = %q, want unspecified", in, got) + } + } +} + +func TestToolMetaFields(t *testing.T) { + meta := tools.ToolMeta{ + Version: "1", + Name: "exec", + Kind: "shell", + Namespace: tools.ToolNamespaceHawkBuild, + Label: "build-tools", + ReadOnly: true, + } + + if meta.Version != "1" { + t.Fatalf("Version = %q", meta.Version) + } + if meta.Name != "exec" { + t.Fatalf("Name = %q", meta.Name) + } + if meta.Kind != "shell" { + t.Fatalf("Kind = %q", meta.Kind) + } + if meta.Namespace != tools.ToolNamespaceHawkBuild { + t.Fatalf("Namespace = %q", meta.Namespace) + } + if meta.Label != "build-tools" { + t.Fatalf("Label = %q", meta.Label) + } + if !meta.ReadOnly { + t.Fatalf("ReadOnly = false, want true") + } +} diff --git a/tools/versioning.go b/tools/versioning.go new file mode 100644 index 0000000..e367932 --- /dev/null +++ b/tools/versioning.go @@ -0,0 +1,144 @@ +package tools + +import "fmt" + +// BehaviorPreset selects a back-compat behavior contract for tool execution. +// FinalizeToolConfigRequest.behavior_version carries one of these presets so a +// single locked configuration can keep older clients working without the server +// having to special-case version strings. +type BehaviorPreset string + +const ( + // BehaviorPresetUnspecified is the zero value; a finalize call SHOULD reject + // it and require an explicit choice. + BehaviorPresetUnspecified BehaviorPreset = "" + // BehaviorPresetCurrent is the actively-supported behavior contract. + BehaviorPresetCurrent BehaviorPreset = "current" + // BehaviorPresetLegacy selects the prior behavior contract for back-compat + // with clients that haven't adopted BehaviorPresetCurrent. + BehaviorPresetLegacy BehaviorPreset = "legacy" +) + +// BehaviorPresetFrom parses a behavior_version string into a BehaviorPreset. +// Returns an error for any unrecognized value — an empty or unknown preset is +// invalid, not silently defaulted (matches the closed-enum forward-safety rule +// applied to ToolNamespace). +func BehaviorPresetFrom(s string) (BehaviorPreset, error) { + switch BehaviorPreset(s) { + case BehaviorPresetCurrent, BehaviorPresetLegacy, BehaviorPresetUnspecified: + return BehaviorPreset(s), nil + default: + return BehaviorPresetUnspecified, fmt.Errorf("tools: unknown behavior preset %q", s) + } +} + +// FinalizeErrorCode classifies a finalize warning or violation, mirroring the +// FINALIZE_ERROR_CODE enum in proto/hawk/contracts/v1/tool.proto. +type FinalizeErrorCode int + +const ( + FinalizeErrorCodeUnspecified FinalizeErrorCode = iota // unspecified + FinalizeErrorCodeUnknownTool // unknown tool id + FinalizeErrorCodeIncompatibleVersion // behavior_version incompatible with enabled_tools + FinalizeErrorCodeDeprecatedReplaced // tool deprecated/replaced + FinalizeErrorCodeInvalidParams // invalid tool parameters +) + +var finalizeErrorCodeNames = [...]string{ + "unspecified", + "unknown_tool", + "incompatible_version", + "deprecated_replaced", + "invalid_params", +} + +func (c FinalizeErrorCode) String() string { + if int(c) < len(finalizeErrorCodeNames) { + return finalizeErrorCodeNames[c] + } + return "unknown" +} + +// VersionWarning is a non-fatal, behavior-version drift reported by finalize. +type VersionWarning struct { + Code FinalizeErrorCode `json:"code"` + Message string `json:"message"` + AffectedTools []string `json:"affected_tools,omitempty"` +} + +// FinalizeConfigViolation is a fatal, deterministic problem that prevents +// finalize from succeeding. +type FinalizeConfigViolation struct { + Code FinalizeErrorCode `json:"code"` + ToolID string `json:"tool_id"` + Message string `json:"message"` +} + +// FinalizeResult is the decoded outcome of a FinalizeToolConfig call. +type FinalizeResult struct { + BehaviorVersion string `json:"behavior_version"` + Warnings []VersionWarning `json:"warnings,omitempty"` + Violations []FinalizeConfigViolation `json:"violations,omitempty"` + Finalized bool `json:"finalized"` +} + +// Ok reports whether finalize succeeded and the tool set is safe to call: +// Finalized must be true AND there must be no fatal violations. Warnings alone +// do not make Ok return false. +func (r FinalizeResult) Ok() bool { + return r.Finalized && len(r.Violations) == 0 +} + +// ToolMeta is the canonical identity envelope attached to tool-call events, +// mirroring the ToolMeta message in proto/hawk/contracts/v1/tool.proto. +// version is an additive-only bump: new additive fields do NOT change it. +type ToolMeta struct { + Version string `json:"version"` + Name string `json:"name"` + Kind string `json:"kind"` + Namespace ToolNamespace `json:"namespace"` + Label string `json:"label,omitempty"` + ReadOnly bool `json:"read_only"` +} + +// ToolNamespace is a CLOSED enum identifying the harness that owns a tool, +// mirroring the ToolNamespace enum in proto/hawk/contracts/v1/tool.proto. A new +// unknown namespace is a wire-breaking change that intentionally fails +// ToolNamespaceFrom (forward-safety): a deploy that rolls the contract forward +// before the consumer code cannot silently mis-route a tool it doesn't +// understand. ToolNamespaceAcp is reserved for the forthcoming hawk-acp repo. +type ToolNamespace string + +const ( + ToolNamespaceUnspecified ToolNamespace = "" // unspecified + ToolNamespaceHawkBuild ToolNamespace = "hawk_build" // hawk + ToolNamespaceHawkBuildConcise ToolNamespace = "hawk_build_concise" + ToolNamespaceCodex ToolNamespace = "codex" // codex harness + ToolNamespaceOpencode ToolNamespace = "opencode" // opencode harness + ToolNamespaceMcp ToolNamespace = "mcp" // MCP servers (hawk-mcpkit) + ToolNamespaceAcp ToolNamespace = "acp" // reserved: hawk-acp +) + +// ToolNamespaceFrom parses a namespace string into a ToolNamespace. The set is +// closed: an unrecognized value returns an error rather than silently mapping +// to ToolNamespaceUnspecified, so a new namespace on the wire fails strict +// typed deserialization (see the closed-enum forward-safety rule above). +func ToolNamespaceFrom(s string) (ToolNamespace, error) { + switch ToolNamespace(s) { + case ToolNamespaceUnspecified, ToolNamespaceHawkBuild, + ToolNamespaceHawkBuildConcise, ToolNamespaceCodex, + ToolNamespaceOpencode, ToolNamespaceMcp, ToolNamespaceAcp: + return ToolNamespace(s), nil + default: + return ToolNamespaceUnspecified, fmt.Errorf("tools: unknown tool namespace %q", s) + } +} + +// String returns the namespace string as-is, mapping the unspecified zero +// value to "unspecified" so log output and JSON stay unambiguous. +func (n ToolNamespace) String() string { + if n == ToolNamespaceUnspecified { + return "unspecified" + } + return string(n) +}