diff --git a/examples/README.md b/examples/README.md index efaebe5..60a7fef 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,7 @@ Runnable examples for common [HOL Standards SDK](https://hol.org/docs/libraries/ | Example | Specification | Description | | ------- | ------------- | ----------- | | `hcs2-create-registry` | [HCS-2](https://hol.org/docs/standards/hcs-2) | Creates an indexed topic registry | +| `registry-broker-delegation` | Registry Broker | Calls `Delegate` and prints ranked delegation opportunities | | `hcs5-build-mint` | [HCS-5](https://hol.org/docs/standards/hcs-5) | Builds a Hashinal mint transaction | | `hcs6-create-registry` | [HCS-6](https://hol.org/docs/standards/hcs-6) | Creates a dynamic hashinal registry | | `hcs7-register-metadata` | [HCS-7](https://hol.org/docs/standards/hcs-7) | Registers metadata in an indexed registry | @@ -19,8 +20,8 @@ Runnable examples for common [HOL Standards SDK](https://hol.org/docs/libraries/ | `hcs20-deploy-points` | [HCS-20](https://hol.org/docs/standards/hcs-20) | Deploys a private points topic | | `hcs21-build-declaration` | [HCS-21](https://hol.org/docs/standards/hcs-21) | Builds an adapter declaration | | `hcs26-parse-memos` | [HCS-26](https://hol.org/docs/standards/hcs-26) | Parses topic/transaction memos | -| `hcs27-publish-checkpoint` | [HCS-27](https://hol.org/docs/standards/hcs-27) | Publishes inline + HRL-backed checkpoints and validates the chain | -| `inscriber-auth-client` | [HCS-1](https://hol.org/docs/standards/hcs-1) | Authenticates with the inscriber service | +| `hcs27-publish-checkpoint` | [HCS-27](https://hol.org/docs/standards/hcs-27) | Publishes a Merkle checkpoint | +| `inscriber-auth-client` | [HCS-1](https://hol.org/docs/standards/hcs-1) | Authenticates with Kiloscribe | | `registry-broker-skill-domain-proof` | [HCS-26](https://hol.org/docs/standards/hcs-26) | Creates/verifies DNS TXT proof and checks trust delta | | `registry-broker-uaid-dns-verification` | [HCS-14](https://hol.org/docs/standards/hcs-14) | Verifies UAID DNS TXT binding and reads stored/live verification status | @@ -33,6 +34,7 @@ go run ./examples/hcs2-create-registry ## CodeSandbox - [`hcs2-create-registry`](https://codesandbox.io/s/github/hashgraph-online/standards-sdk-go/tree/main/examples/hcs2-create-registry) +- [`registry-broker-delegation`](https://codesandbox.io/s/github/hashgraph-online/standards-sdk-go/tree/main/examples/registry-broker-delegation) - [`hcs5-build-mint`](https://codesandbox.io/s/github/hashgraph-online/standards-sdk-go/tree/main/examples/hcs5-build-mint) - [`hcs6-create-registry`](https://codesandbox.io/s/github/hashgraph-online/standards-sdk-go/tree/main/examples/hcs6-create-registry) - [`hcs7-register-metadata`](https://codesandbox.io/s/github/hashgraph-online/standards-sdk-go/tree/main/examples/hcs7-register-metadata) diff --git a/examples/registry-broker-delegation/README.md b/examples/registry-broker-delegation/README.md new file mode 100644 index 0000000..74e25bc --- /dev/null +++ b/examples/registry-broker-delegation/README.md @@ -0,0 +1,20 @@ +# Registry Broker Delegation + +This example calls the broker-native delegation planner and prints the ranked +opportunities plus the top candidate per opportunity. + +Optional environment variables: + +```bash +export REGISTRY_BROKER_BASE_URL="https://hol.org/registry/api/v1" +export REGISTRY_BROKER_API_KEY="..." +export REGISTRY_BROKER_DELEGATION_TASK="Review an SDK PR and split out docs and verification subtasks." +export REGISTRY_BROKER_DELEGATION_CONTEXT="Need a docs-focused sidecar pass." +export REGISTRY_BROKER_DELEGATION_LIMIT="3" +``` + +## Run + +```bash +go run ./examples/registry-broker-delegation +``` diff --git a/examples/registry-broker-delegation/main.go b/examples/registry-broker-delegation/main.go new file mode 100644 index 0000000..9218214 --- /dev/null +++ b/examples/registry-broker-delegation/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/hashgraph-online/standards-sdk-go/pkg/registrybroker" +) + +const ( + defaultBaseURL = "https://hol.org/registry/api/v1" + defaultTask = "Review an SDK PR and split out docs and verification subtasks." +) + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + client, err := registrybroker.NewRegistryBrokerClient(registrybroker.RegistryBrokerClientOptions{ + BaseURL: strings.TrimSpace(getEnvOrDefault("REGISTRY_BROKER_BASE_URL", defaultBaseURL)), + APIKey: strings.TrimSpace(os.Getenv("REGISTRY_BROKER_API_KEY")), + }) + if err != nil { + return err + } + + limit, err := strconv.Atoi(strings.TrimSpace(getEnvOrDefault("REGISTRY_BROKER_DELEGATION_LIMIT", "3"))) + if err != nil || limit <= 0 { + limit = 3 + } + + response, err := client.Delegate(context.Background(), registrybroker.DelegationPlanRequest{ + Task: strings.TrimSpace(getEnvOrDefault("REGISTRY_BROKER_DELEGATION_TASK", defaultTask)), + Context: strings.TrimSpace(os.Getenv("REGISTRY_BROKER_DELEGATION_CONTEXT")), + Limit: limit, + }) + if err != nil { + return err + } + + fmt.Printf("task=%s\n", response.Task) + fmt.Printf("shouldDelegate=%t\n", response.ShouldDelegate) + if response.LocalFirstReason != "" { + fmt.Printf("localFirstReason=%s\n", response.LocalFirstReason) + } + for index := range response.Opportunities { + opportunity := &response.Opportunities[index] + fmt.Printf("\nopportunity=%s title=%s\n", opportunity.ID, opportunity.Title) + fmt.Printf("reason=%s\n", opportunity.Reason) + if len(opportunity.Candidates) == 0 { + fmt.Println("topCandidate=") + continue + } + candidate := opportunity.Candidates[0] + label := candidate.Label + if label == "" { + label = "" + } + fmt.Printf("topCandidate=%s label=%s\n", candidate.UAID, label) + } + return nil +} + +func getEnvOrDefault(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} diff --git a/examples/registry-broker-delegation/main_test.go b/examples/registry-broker-delegation/main_test.go new file mode 100644 index 0000000..56bbbdf --- /dev/null +++ b/examples/registry-broker-delegation/main_test.go @@ -0,0 +1,27 @@ +package main + +import "testing" + +func TestGetEnvOrDefault(t *testing.T) { + t.Run("returns fallback when env var is not set", func(t *testing.T) { + if value := getEnvOrDefault("REGISTRY_BROKER_DELEGATION_EXAMPLE_MISSING", "fallback"); value != "fallback" { + t.Fatalf("expected fallback, got %s", value) + } + }) + + t.Run("returns env var value when set", func(t *testing.T) { + t.Setenv("REGISTRY_BROKER_DELEGATION_EXAMPLE_SET", "env-value") + + if value := getEnvOrDefault("REGISTRY_BROKER_DELEGATION_EXAMPLE_SET", "fallback"); value != "env-value" { + t.Fatalf("expected env-value, got %s", value) + } + }) + + t.Run("returns trimmed env var value when set with whitespace", func(t *testing.T) { + t.Setenv("REGISTRY_BROKER_DELEGATION_EXAMPLE_SPACED", " env-value ") + + if value := getEnvOrDefault("REGISTRY_BROKER_DELEGATION_EXAMPLE_SPACED", "fallback"); value != "env-value" { + t.Fatalf("expected env-value, got %s", value) + } + }) +} diff --git a/pkg/registrybroker/delegation_plan_test.go b/pkg/registrybroker/delegation_plan_test.go new file mode 100644 index 0000000..0e8b1cc --- /dev/null +++ b/pkg/registrybroker/delegation_plan_test.go @@ -0,0 +1,134 @@ +package registrybroker + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +const preservedValue = "preserved" + +func TestDelegate(t *testing.T) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/delegate" { + t.Fatalf("expected /api/v1/delegate, got %s", r.URL.Path) + } + + var request DelegationPlanRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request.Task != "Review SDK PR feedback" { + t.Fatalf("expected task to round-trip, got %q", request.Task) + } + if request.Filter == nil || len(request.Filter.Protocols) != 1 || request.Filter.Protocols[0] != "mcp" { + t.Fatalf("expected protocols filter to round-trip, got %#v", request.Filter) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "task": "Review SDK PR feedback", + "context": "Need a docs-focused sidecar pass.", + "summary": "Delegate documentation follow-up.", + "shouldDelegate": true, + "localFirstReason": "Main agent owns the implementation work.", + "recommendation": { "summary": "Delegate docs only", "mode": "parallel" }, + "opportunities": [ + { + "id": "docs", + "title": "Docs follow-up", + "reason": "Bounded copy update", + "role": "docs", + "type": "sidecar", + "suggestedMode": "parallel", + "searchQueries": ["docs markdown docusaurus"], + "extraOpportunityField": "%s", + "candidates": [ + { + "uaid": "uaid-1", + "label": "Docs Agent", + "registry": "hcs-11", + "score": 0.98, + "trustScore": 0.91, + "verified": true, + "communicationSupported": true, + "availability": "online", + "explanation": "Strong docs match", + "matchedRoles": ["docs"], + "reasons": ["Strong docs match"], + "suggestedMessage": "Update the docs tab set.", + "extraCandidateField": "%s", + "agent": { + "name": "Docs Agent", + "verified": true, + "extraAgentField": "%s" + } + } + ] + } + ], + "extraRootField": "%s" + }`, preservedValue, preservedValue, preservedValue, preservedValue) + })) + defer server.Close() + + client, err := NewRegistryBrokerClient(RegistryBrokerClientOptions{BaseURL: server.URL}) + if err != nil { + t.Fatalf("new client: %v", err) + } + + response, err := client.Delegate(context.Background(), DelegationPlanRequest{ + Task: "Review SDK PR feedback", + Context: "Need a docs-focused sidecar pass.", + Limit: 2, + Filter: &DelegationPlanFilter{ + Protocols: []string{"mcp"}, + Type: "mcp_server", + }, + Workspace: JSONObject{"repo": "hashgraph-online/standards-sdk"}, + }) + if err != nil { + t.Fatalf("delegate: %v", err) + } + + if !response.ShouldDelegate { + t.Fatal("expected delegation recommendation") + } + if response.Context != "Need a docs-focused sidecar pass." { + t.Fatalf("expected context to parse, got %q", response.Context) + } + if len(response.Opportunities) != 1 { + t.Fatalf("expected one opportunity, got %d", len(response.Opportunities)) + } + if response.Extras["extraRootField"] != preservedValue { + t.Fatalf("expected additive root field to survive, got %#v", response.Extras) + } + opportunity := response.Opportunities[0] + if opportunity.Extras["extraOpportunityField"] != preservedValue { + t.Fatalf("expected additive opportunity field to survive, got %#v", opportunity.Extras) + } + candidate := opportunity.Candidates[0] + if candidate.Registry != "hcs-11" { + t.Fatalf("expected registry to parse, got %q", candidate.Registry) + } + if candidate.TrustScore != 0.91 { + t.Fatalf("expected trust score 0.91, got %f", candidate.TrustScore) + } + if candidate.Verified == nil || !*candidate.Verified { + t.Fatal("expected verified candidate") + } + if candidate.Extras["extraCandidateField"] != preservedValue { + t.Fatalf("expected additive candidate field to survive, got %#v", candidate.Extras) + } + if candidate.Agent["extraAgentField"] != preservedValue { + t.Fatalf("expected additive agent field to survive, got %#v", candidate.Agent) + } +} diff --git a/pkg/registrybroker/search.go b/pkg/registrybroker/search.go index f60fd10..b11da3b 100644 --- a/pkg/registrybroker/search.go +++ b/pkg/registrybroker/search.go @@ -2,6 +2,7 @@ package registrybroker import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -15,6 +16,39 @@ func (c *RegistryBrokerClient) Search(ctx context.Context, params SearchParams) return c.requestJSON(ctx, http.MethodGet, path, nil, nil) } +// Delegate returns a typed delegation plan for the requested task. +func (c *RegistryBrokerClient) Delegate( + ctx context.Context, + request DelegationPlanRequest, +) (DelegationPlanResponse, error) { + var result DelegationPlanResponse + + rawBody, rawHeaders, err := c.request( + ctx, + http.MethodPost, + "/delegate", + request, + map[string]string{"content-type": "application/json"}, + ) + if err != nil { + return result, err + } + if !isJSONContentType(rawHeaders.Get("content-type")) { + return result, &RegistryBrokerParseError{ + Message: "expected JSON response from registry broker", + Body: strings.TrimSpace(string(rawBody)), + } + } + if err := json.Unmarshal(rawBody, &result); err != nil { + return result, &RegistryBrokerParseError{ + Message: "failed to decode registry broker response", + Body: strings.TrimSpace(string(rawBody)), + Cause: err, + } + } + return result, nil +} + // SearchErc8004ByAgentID performs the requested operation. func (c *RegistryBrokerClient) SearchErc8004ByAgentID( ctx context.Context, diff --git a/pkg/registrybroker/types.go b/pkg/registrybroker/types.go index f2138ff..ca65c01 100644 --- a/pkg/registrybroker/types.go +++ b/pkg/registrybroker/types.go @@ -2,6 +2,7 @@ package registrybroker import ( "context" + "encoding/json" "net/http" "time" ) @@ -62,6 +63,197 @@ type SearchParams struct { SortOrder string } +type DelegationPlanFilter struct { + Registry string `json:"registry,omitempty"` + Registries []string `json:"registries,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Protocols []string `json:"protocols,omitempty"` + Adapters []string `json:"adapters,omitempty"` + Type string `json:"type,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Languages []string `json:"languages,omitempty"` + Artifacts []string `json:"artifacts,omitempty"` +} + +type DelegationPlanRequest struct { + Task string `json:"task"` + Context string `json:"context,omitempty"` + Limit int `json:"limit,omitempty"` + Filter *DelegationPlanFilter `json:"filter,omitempty"` + Workspace JSONObject `json:"workspace,omitempty"` +} + +type DelegationPlanRecommendation struct { + Summary string `json:"summary,omitempty"` + Mode string `json:"mode,omitempty"` +} + +type DelegationPlanCandidate struct { + UAID string `json:"uaid"` + Label string `json:"label,omitempty"` + Registry string `json:"registry,omitempty"` + Score float64 `json:"score,omitempty"` + TrustScore float64 `json:"trustScore,omitempty"` + Verified *bool `json:"verified,omitempty"` + CommunicationSupported *bool `json:"communicationSupported,omitempty"` + Availability string `json:"availability,omitempty"` + Explanation string `json:"explanation,omitempty"` + MatchedQueries []string `json:"matchedQueries,omitempty"` + MatchedRoles []string `json:"matchedRoles,omitempty"` + MatchedProtocols []string `json:"matchedProtocols,omitempty"` + MatchedSurfaces []string `json:"matchedSurfaces,omitempty"` + MatchedLanguages []string `json:"matchedLanguages,omitempty"` + MatchedArtifacts []string `json:"matchedArtifacts,omitempty"` + MatchedTaskTags []string `json:"matchedTaskTags,omitempty"` + Reasons []string `json:"reasons,omitempty"` + SuggestedMessage string `json:"suggestedMessage,omitempty"` + Agent JSONObject `json:"agent,omitempty"` + Extras JSONObject `json:"-"` +} + +type DelegationOpportunity struct { + ID string `json:"id"` + Title string `json:"title"` + Reason string `json:"reason"` + Role string `json:"role,omitempty"` + Type string `json:"type,omitempty"` + SuggestedMode string `json:"suggestedMode,omitempty"` + SearchQueries []string `json:"searchQueries,omitempty"` + Protocols []string `json:"protocols,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Languages []string `json:"languages,omitempty"` + Artifacts []string `json:"artifacts,omitempty"` + Candidates []DelegationPlanCandidate `json:"candidates,omitempty"` + Extras JSONObject `json:"-"` +} + +type DelegationPlanResponse struct { + Task string `json:"task"` + Context string `json:"context,omitempty"` + Summary string `json:"summary,omitempty"` + Intents []string `json:"intents,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Protocols []string `json:"protocols,omitempty"` + Languages []string `json:"languages,omitempty"` + Artifacts []string `json:"artifacts,omitempty"` + ShouldDelegate bool `json:"shouldDelegate"` + LocalFirstReason string `json:"localFirstReason,omitempty"` + Recommendation *DelegationPlanRecommendation `json:"recommendation,omitempty"` + Opportunities []DelegationOpportunity `json:"opportunities,omitempty"` + Extras JSONObject `json:"-"` +} + +func (c *DelegationPlanCandidate) UnmarshalJSON(data []byte) error { + type candidateAlias DelegationPlanCandidate + aux := candidateAlias{} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + candidate := DelegationPlanCandidate(aux) + extras, err := extractUnknownFields( + data, + "uaid", + "label", + "registry", + "score", + "trustScore", + "verified", + "communicationSupported", + "availability", + "explanation", + "matchedQueries", + "matchedRoles", + "matchedProtocols", + "matchedSurfaces", + "matchedLanguages", + "matchedArtifacts", + "matchedTaskTags", + "reasons", + "suggestedMessage", + "agent", + ) + if err != nil { + return err + } + candidate.Extras = extras + *c = candidate + return nil +} + +func (o *DelegationOpportunity) UnmarshalJSON(data []byte) error { + type opportunityAlias DelegationOpportunity + aux := opportunityAlias{} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + opportunity := DelegationOpportunity(aux) + extras, err := extractUnknownFields( + data, + "id", + "title", + "reason", + "role", + "type", + "suggestedMode", + "searchQueries", + "protocols", + "surfaces", + "languages", + "artifacts", + "candidates", + ) + if err != nil { + return err + } + opportunity.Extras = extras + *o = opportunity + return nil +} + +func (r *DelegationPlanResponse) UnmarshalJSON(data []byte) error { + type responseAlias DelegationPlanResponse + aux := responseAlias{} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + response := DelegationPlanResponse(aux) + extras, err := extractUnknownFields( + data, + "task", + "context", + "summary", + "intents", + "surfaces", + "protocols", + "languages", + "artifacts", + "shouldDelegate", + "localFirstReason", + "recommendation", + "opportunities", + ) + if err != nil { + return err + } + response.Extras = extras + *r = response + return nil +} + +func extractUnknownFields(data []byte, knownKeys ...string) (JSONObject, error) { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + for _, key := range knownKeys { + delete(payload, key) + } + if len(payload) == 0 { + return JSONObject{}, nil + } + return JSONObject(payload), nil +} + type VectorSearchFilter struct { Registry string `json:"registry,omitempty"` Protocols []string `json:"protocols,omitempty"` @@ -311,10 +503,6 @@ type ListSkillsOptions struct { AccountID string } -type SkillSecurityBreakdownOptions struct { - JobID string -} - type ListMySkillsOptions struct { Limit *int } @@ -325,6 +513,10 @@ type MySkillsListOptions struct { AccountID string } +type SkillSecurityBreakdownOptions struct { + JobID string +} + type SkillPublishJobOptions struct { AccountID string }