Pulse/internal/agentcapabilities/schema.go
2026-07-11 15:19:41 +01:00

1062 lines
38 KiB
Go

package agentcapabilities
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// Tool describes a structured tool definition with a JSON object input schema.
// The Assistant registry owns concrete tool handlers, while this shared shape
// keeps registry-to-provider projection aligned with external agent adapters.
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema InputSchema `json:"inputSchema"`
}
// NormalizeCollections keeps registry tool definitions stable when they cross
// Assistant, provider, and external-agent boundaries.
func (t Tool) NormalizeCollections() Tool {
t.InputSchema = t.InputSchema.NormalizeCollections()
return t
}
// ProviderTool is the neutral chat-provider tool declaration projected from the
// Assistant registry. Provider clients map this into their supported upstream
// function schema fields, while Pulse-owned metadata stays available to local
// runtime and contract checks without being blindly forwarded.
type ProviderTool struct {
Type string `json:"type,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]interface{} `json:"input_schema"`
MaxUses int `json:"max_uses,omitempty"`
BehaviorHints *ToolBehaviorHints `json:"behavior_hints,omitempty"`
PulseGovernance *ToolGovernanceDescriptor `json:"pulse_governance,omitempty"`
}
// AssistantProviderToolOptions controls native Assistant surface-tool
// projection. Registry tools are always projected from the shared governed
// manifest; native interactive tools are opt-in because Patrol/autonomous runs
// cannot wait on in-app user input.
type AssistantProviderToolOptions struct {
IncludeQuestionTool bool
}
// EmptyProviderTool returns a provider tool with initialized collection fields.
func EmptyProviderTool() ProviderTool {
return ProviderTool{}.NormalizeCollections()
}
// NormalizeCollections keeps provider tool JSON stable by preserving an empty
// input_schema object instead of marshaling it as null.
func (t ProviderTool) NormalizeCollections() ProviderTool {
t.InputSchema = CloneProviderInputSchema(t.InputSchema)
if t.InputSchema == nil {
t.InputSchema = map[string]interface{}{}
}
t.BehaviorHints = CloneToolBehaviorHints(t.BehaviorHints)
if t.PulseGovernance != nil {
governance := *t.PulseGovernance
t.PulseGovernance = &governance
}
return t
}
// ProviderToolCall is the provider-facing tool invocation shape returned by
// chat model clients. Keeping it here lets Assistant and external-agent
// adapters share the provider-call to tools/call bridge.
type ProviderToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
ThoughtSignature json.RawMessage `json:"thought_signature,omitempty"`
}
// EmptyProviderToolCall returns a provider tool call with initialized
// collection fields.
func EmptyProviderToolCall() ProviderToolCall {
return ProviderToolCall{}.NormalizeCollections()
}
// NormalizeCollections keeps provider tool-call JSON stable by preserving an
// empty input object instead of marshaling it as null.
func (t ProviderToolCall) NormalizeCollections() ProviderToolCall {
t.Input = CloneToolArguments(t.Input)
t.ThoughtSignature = CloneRawMessage(t.ThoughtSignature)
if t.Input == nil {
t.Input = map[string]interface{}{}
}
return t
}
// ProviderToolResult is the provider-facing response shape sent back to model
// clients after a tool invocation is executed.
type ProviderToolResult struct {
ToolUseID string `json:"tool_use_id"`
Content string `json:"content"`
IsError bool `json:"is_error,omitempty"`
}
// ProviderToolResultContextOptions controls how a provider tool result is
// projected into the model-facing context while preserving the full transcript
// result. A non-positive MaxModelContentChars disables truncation.
type ProviderToolResultContextOptions struct {
MaxModelContentChars int
}
// ProviderToolResultTruncation describes the model-context truncation applied
// while projecting a provider tool result.
type ProviderToolResultTruncation struct {
Applied bool
OriginalChars int
MaxChars int
TruncatedChars int
}
// ProviderToolResultContextProjection carries the full transcript result and
// the result sent back to the model for the next provider turn.
type ProviderToolResultContextProjection struct {
Transcript ProviderToolResult
Model ProviderToolResult
Truncation ProviderToolResultTruncation
}
// NewProviderToolResult builds the provider-facing result shape in one place so
// Assistant turns and external-agent adapters do not duplicate tool-result JSON.
func NewProviderToolResult(toolUseID, content string, isError bool) ProviderToolResult {
return ProviderToolResult{
ToolUseID: toolUseID,
Content: content,
IsError: isError,
}
}
// NewProviderToolErrorResult builds a provider-facing synthetic error result.
func NewProviderToolErrorResult(toolUseID, content string) ProviderToolResult {
return NewProviderToolResult(toolUseID, content, true)
}
// NewProviderToolResultFromToolResult projects a shared tool result into the
// provider context shape sent back to chat models.
func NewProviderToolResultFromToolResult(toolUseID string, result ToolResult) ProviderToolResult {
interpreted := InterpretToolResult(result)
return NewProviderToolResult(toolUseID, interpreted.Text, interpreted.IsError)
}
// NewProviderToolResultContextProjection builds the paired transcript/model
// provider results in one shared place. The transcript result retains full
// content; the model result applies the supplied model-context limit.
func NewProviderToolResultContextProjection(toolUseID, content string, isError bool, opts ProviderToolResultContextOptions) ProviderToolResultContextProjection {
modelContent, truncation := ProviderToolResultModelContent(content, opts)
return ProviderToolResultContextProjection{
Transcript: NewProviderToolResult(toolUseID, content, isError),
Model: NewProviderToolResult(toolUseID, modelContent, isError),
Truncation: truncation,
}
}
// NewProviderToolResultContextProjectionFromToolResult projects a shared tool
// result into the paired transcript/model provider results used by native
// Assistant execution and external adapters with provider context.
func NewProviderToolResultContextProjectionFromToolResult(toolUseID string, result ToolResult, opts ProviderToolResultContextOptions) ProviderToolResultContextProjection {
interpreted := InterpretToolResult(result)
return NewProviderToolResultContextProjection(toolUseID, interpreted.Text, interpreted.IsError, opts)
}
// ProviderToolResultModelContent applies the shared provider tool-result
// truncation notice used for model context. The caller supplies the limit so
// each surface keeps owning its model-context budget.
func ProviderToolResultModelContent(content string, opts ProviderToolResultContextOptions) (string, ProviderToolResultTruncation) {
maxChars := opts.MaxModelContentChars
if maxChars <= 0 || len(content) <= maxChars {
return content, ProviderToolResultTruncation{}
}
truncatedChars := len(content) - maxChars
truncated := content[:maxChars]
return fmt.Sprintf("%s\n\n---\n[TRUNCATED: %d characters cut. The result was too large. If you need specific details that may have been cut, make a more targeted query (e.g., filter by specific resource or type).]", truncated, truncatedChars), ProviderToolResultTruncation{
Applied: true,
OriginalChars: len(content),
MaxChars: maxChars,
TruncatedChars: truncatedChars,
}
}
// ProjectProviderToolCallToToolCall projects a model-chosen provider tool call
// into the shared tool-call parameter envelope used by Assistant tool execution
// and external-agent adapters.
func ProjectProviderToolCallToToolCall(tc ProviderToolCall) ToolCallParams {
return NormalizeToolCallParams(ToolCallParams{
Name: tc.Name,
Arguments: tc.Input,
})
}
// NormalizeProviderToolCallForExecution returns the provider call shape that
// Assistant should execute after projecting through the shared tools/call
// contract. The provider id and thought signature stay attached for transcript
// and provider-continuation use, while name/arguments are normalized exactly as
// the shared tool-call contract sees them.
func NormalizeProviderToolCallForExecution(tc ProviderToolCall) ProviderToolCall {
normalized := tc.NormalizeCollections()
params := ProjectProviderToolCallToToolCall(tc)
normalized.Name = params.Name
normalized.Input = params.Arguments
return normalized
}
// NormalizeProviderToolCallsForExecution preserves provider order while
// normalizing every call through the shared tools/call projection.
func NormalizeProviderToolCallsForExecution(calls []ProviderToolCall) []ProviderToolCall {
normalized := make([]ProviderToolCall, 0, len(calls))
for _, call := range calls {
normalized = append(normalized, NormalizeProviderToolCallForExecution(call))
}
return normalized
}
// NewPulseQuestionProviderTool returns the native Assistant structured
// clarification tool declaration. It is intentionally provider-facing only:
// pulse_question is not a manifest capability or MCP tool, but its identity and
// schema still belong to the shared Pulse Intelligence provider-tool contract
// so native chat does not hand-roll tool policy locally.
func NewPulseQuestionProviderTool() ProviderTool {
return ProviderTool{
Name: PulseQuestionToolName,
Description: "Ask the user for missing information using a structured prompt. Use this when you must clarify before proceeding (e.g., choose a target, confirm a risky action, or select among options).",
InputSchema: PulseQuestionProviderInputSchema(),
}.NormalizeCollections()
}
const (
// LegacyAssistantRunCommandToolName is retained only to recognize and deny
// old persisted/provider calls. It is never projected to a model.
LegacyAssistantRunCommandToolName = "run_command"
// LegacyAssistantFetchURLToolName is the compatibility alias for the older
// native Assistant URL fetch helper.
LegacyAssistantFetchURLToolName = "fetch_url"
// LegacyAssistantSetResourceURLToolName is the compatibility alias for the
// older native Assistant resource URL metadata helper.
LegacyAssistantSetResourceURLToolName = "set_resource_url"
LegacyAssistantCommandArgumentName = "command"
LegacyAssistantRunOnHostArgumentName = "run_on_host"
LegacyAssistantTargetHostArgumentName = "target_host"
LegacyAssistantURLArgumentName = "url"
LegacyAssistantResourceTypeArgumentName = "resource_type"
LegacyAssistantResourceIDArgumentName = "resource_id"
)
// LegacyAssistantUtilityProviderTools returns the provider-facing compatibility
// tools still exposed by the older native Assistant service. The definitions
// live in the shared provider-tool contract so legacy Assistant aliases can be
// bridged toward the registry-backed Pulse Intelligence core without local
// schema copies.
func LegacyAssistantUtilityProviderTools() []ProviderTool {
return ProjectProviderTools([]Tool{
legacyAssistantFetchURLTool(),
legacyAssistantSetResourceURLTool(),
})
}
func legacyAssistantFetchURLTool() Tool {
return Tool{
Name: LegacyAssistantFetchURLToolName,
Description: "Fetch content from a URL. Use this to check if web services are responding, read API endpoints, or fetch documentation. Works with local network URLs and public sites.",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
LegacyAssistantURLArgumentName: {
Type: "string",
Description: "The URL to fetch (e.g., 'http://192.0.2.50:8080/api/health' or 'https://example.com/docs')",
},
},
Required: []string{LegacyAssistantURLArgumentName},
},
}
}
func legacyAssistantSetResourceURLTool() Tool {
return Tool{
Name: LegacyAssistantSetResourceURLToolName,
Description: "Set the web URL for a resource in Pulse after discovering a web service. Use this when you've found a web server running on a VM/container/host and want to save it for quick access. The URL will appear as a clickable link in the Pulse dashboard.",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
LegacyAssistantResourceTypeArgumentName: {
Type: "string",
Description: "Canonical v6 resource type: 'vm', 'system-container', 'oci-container', 'app-container', 'agent', 'node', or 'docker-host'",
Enum: []string{"vm", "system-container", "oci-container", "app-container", "agent", "node", "docker-host"},
},
LegacyAssistantResourceIDArgumentName: {
Type: "string",
Description: "The resource ID from context. For VMs/LXC, use the canonical resource ID shown by Pulse (for example 'homelab:pve-node:150'). For app containers, use the container resource ID (for example 'hostid:container:containerid').",
},
LegacyAssistantURLArgumentName: {
Type: "string",
Description: "The discovered URL (e.g., 'http://192.0.2.50:8096' for Jellyfin). Use an empty string to remove the URL.",
},
},
Required: []string{LegacyAssistantResourceTypeArgumentName, LegacyAssistantResourceIDArgumentName},
},
}
}
// PulseQuestionToolType is the shared value vocabulary for the native
// Assistant structured clarification tool's `type` field.
type PulseQuestionToolType string
const (
PulseQuestionToolTypeText PulseQuestionToolType = "text"
PulseQuestionToolTypeSelect PulseQuestionToolType = "select"
)
// PulseQuestionToolTypeValues returns the provider-schema enum for
// pulse_question question types.
func PulseQuestionToolTypeValues() []string {
return []string{string(PulseQuestionToolTypeText), string(PulseQuestionToolTypeSelect)}
}
// NormalizePulseQuestionToolType applies the shared pulse_question type
// defaulting and validation rule used by provider schemas and native Assistant
// input parsing.
func NormalizePulseQuestionToolType(rawType string, hasOptions bool) (PulseQuestionToolType, error) {
normalizedType := strings.TrimSpace(strings.ToLower(rawType))
if normalizedType == "" {
if hasOptions {
normalizedType = string(PulseQuestionToolTypeSelect)
} else {
normalizedType = string(PulseQuestionToolTypeText)
}
}
switch PulseQuestionToolType(normalizedType) {
case PulseQuestionToolTypeText:
return PulseQuestionToolTypeText, nil
case PulseQuestionToolTypeSelect:
if !hasOptions {
return "", fmt.Errorf("select questions must include options")
}
return PulseQuestionToolTypeSelect, nil
default:
return "", fmt.Errorf("question.type must be 'text' or 'select'")
}
}
// PulseQuestionToolQuestion is the normalized in-app Assistant question payload
// parsed from the shared pulse_question provider tool input.
type PulseQuestionToolQuestion struct {
ID string
Type PulseQuestionToolType
Header string
Question string
Options []PulseQuestionToolOption
}
// PulseQuestionToolOption is one normalized option for a select-style
// pulse_question entry.
type PulseQuestionToolOption struct {
Label string
Value string
Description string
}
type pulseQuestionToolInputPayload struct {
Questions json.RawMessage `json:"questions"`
}
type pulseQuestionToolInputQuestion struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Header string `json:"header,omitempty"`
Question string `json:"question"`
Options json.RawMessage `json:"options,omitempty"`
}
type pulseQuestionToolInputOption struct {
Label string `json:"label"`
Value string `json:"value,omitempty"`
Description string `json:"description,omitempty"`
}
// ParsePulseQuestionToolInput applies the same validation and normalization as
// the advertised pulse_question provider schema. Native Assistant code should
// adapt the returned values into UI-specific question events rather than owning
// a second parser.
func ParsePulseQuestionToolInput(input map[string]interface{}) ([]PulseQuestionToolQuestion, error) {
payloadBytes, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("invalid question payload: %w", err)
}
var payload pulseQuestionToolInputPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, fmt.Errorf("invalid question payload: %w", err)
}
if len(payload.Questions) == 0 {
return nil, fmt.Errorf("missing required field: questions")
}
var rawQuestions []json.RawMessage
if err := json.Unmarshal(payload.Questions, &rawQuestions); err != nil {
return nil, fmt.Errorf("questions must be an array")
}
if len(rawQuestions) == 0 {
return nil, fmt.Errorf("questions must not be empty")
}
questions := make([]PulseQuestionToolQuestion, 0, len(rawQuestions))
for _, rawQuestion := range rawQuestions {
var parsedQuestion pulseQuestionToolInputQuestion
if err := json.Unmarshal(rawQuestion, &parsedQuestion); err != nil {
return nil, fmt.Errorf("invalid question entry")
}
id := strings.TrimSpace(parsedQuestion.ID)
questionText := strings.TrimSpace(parsedQuestion.Question)
header := strings.TrimSpace(parsedQuestion.Header)
if id == "" {
return nil, fmt.Errorf("question.id is required")
}
if questionText == "" {
return nil, fmt.Errorf("question.question is required")
}
opts, err := parsePulseQuestionToolOptions(parsedQuestion.Options)
if err != nil {
return nil, err
}
qType, err := NormalizePulseQuestionToolType(parsedQuestion.Type, len(opts) > 0)
if err != nil {
return nil, err
}
questions = append(questions, PulseQuestionToolQuestion{
ID: id,
Type: qType,
Header: header,
Question: questionText,
Options: opts,
})
}
return questions, nil
}
func parsePulseQuestionToolOptions(rawOptions json.RawMessage) ([]PulseQuestionToolOption, error) {
if len(rawOptions) == 0 {
return nil, nil
}
trimmed := bytes.TrimSpace(rawOptions)
if bytes.Equal(trimmed, []byte("null")) {
return nil, fmt.Errorf("question.options must be an array")
}
var decodedOptions []pulseQuestionToolInputOption
if err := json.Unmarshal(rawOptions, &decodedOptions); err != nil {
return nil, fmt.Errorf("question.options must be an array")
}
options := make([]PulseQuestionToolOption, 0, len(decodedOptions))
for _, decodedOption := range decodedOptions {
label := strings.TrimSpace(decodedOption.Label)
value := strings.TrimSpace(decodedOption.Value)
desc := strings.TrimSpace(decodedOption.Description)
if label == "" {
return nil, fmt.Errorf("option.label is required")
}
if value == "" {
value = label
}
options = append(options, PulseQuestionToolOption{
Label: label,
Value: value,
Description: desc,
})
}
return options, nil
}
// PulseQuestionProviderInputSchema returns the provider-facing JSON Schema for
// the native Assistant structured clarification tool.
func PulseQuestionProviderInputSchema() map[string]interface{} {
return CloneProviderInputSchema(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"questions": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "Stable identifier for this question (used in the answer payload).",
},
"type": map[string]interface{}{
"type": "string",
"description": "Question type. Use 'text' for free-form input or 'select' for predefined options.",
"enum": PulseQuestionToolTypeValues(),
},
"header": map[string]interface{}{
"type": "string",
"description": "Optional short context shown above the question.",
},
"question": map[string]interface{}{
"type": "string",
"description": "The question shown to the user.",
},
"options": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"label": map[string]interface{}{"type": "string"},
"value": map[string]interface{}{"type": "string"},
"description": map[string]interface{}{
"type": "string",
"description": "Optional detail shown under the label.",
},
},
"required": []string{"label"},
},
},
},
"required": []string{"id", "question"},
},
},
},
"required": []string{"questions"},
})
}
// ParseProviderToolInput parses a provider-emitted tool input JSON object. It
// returns ok=false for incomplete streamed JSON, malformed JSON, empty input, or
// non-object values so stream progress surfaces can keep showing raw input while
// the provider is still emitting arguments.
func ParseProviderToolInput(raw string) (map[string]interface{}, bool) {
if strings.TrimSpace(raw) == "" {
return nil, false
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(raw), &input); err != nil || input == nil {
return nil, false
}
for name := range input {
if IsInternalToolArgument(name) {
return nil, false
}
}
return input, true
}
// ProviderToolInputOrRaw returns a parsed provider tool input object, or the
// legacy-compatible raw fallback used when a provider emits malformed,
// incomplete, or empty arguments at final tool-call assembly time.
func ProviderToolInputOrRaw(raw string) map[string]interface{} {
if input, ok := ParseProviderToolInput(raw); ok {
return input
}
return map[string]interface{}{"raw": raw}
}
// InputSchema describes the expected object input for a tool.
type InputSchema struct {
Type string `json:"type"` // Always "object"
Properties map[string]PropertySchema `json:"properties"`
Required []string `json:"required,omitempty"`
}
// NormalizeCollections returns an independent JSON-object schema with stable
// empty collections.
func (s InputSchema) NormalizeCollections() InputSchema {
if s.Type == "" {
s.Type = "object"
}
s.Properties = ClonePropertySchemas(s.Properties)
if s.Properties == nil {
s.Properties = map[string]PropertySchema{}
}
s.Required = append([]string(nil), s.Required...)
return s
}
// PropertySchema describes a property in a structured tool input schema.
type PropertySchema struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
Default interface{} `json:"default,omitempty"`
}
// NormalizeCollections returns an independent property schema for use in
// cross-surface tool declarations.
func (p PropertySchema) NormalizeCollections() PropertySchema {
p.Enum = append([]string(nil), p.Enum...)
p.Default = cloneSchemaValue(p.Default)
return p
}
// ClonePropertySchemas returns an independent copy of structured tool property
// schemas.
func ClonePropertySchemas(properties map[string]PropertySchema) map[string]PropertySchema {
if properties == nil {
return nil
}
cloned := make(map[string]PropertySchema, len(properties))
for name, property := range properties {
cloned[name] = property.NormalizeCollections()
}
return cloned
}
// CloneProviderInputSchema returns an independent copy of a provider JSON
// Schema map.
func CloneProviderInputSchema(schema map[string]interface{}) map[string]interface{} {
if schema == nil {
return nil
}
cloned := make(map[string]interface{}, len(schema))
for name, value := range schema {
cloned[name] = cloneSchemaValue(value)
}
return cloned
}
func cloneSchemaValue(value interface{}) interface{} {
switch typed := value.(type) {
case map[string]interface{}:
cloned := make(map[string]interface{}, len(typed))
for key, child := range typed {
cloned[key] = cloneSchemaValue(child)
}
return cloned
case []interface{}:
cloned := make([]interface{}, len(typed))
for i, child := range typed {
cloned[i] = cloneSchemaValue(child)
}
return cloned
case []string:
return append([]string(nil), typed...)
default:
return value
}
}
// ObjectInputSchemaMap builds the common JSON Schema object envelope used by
// Pulse Intelligence capability manifests and adapters. Strict manifest-owned
// schemas should pass additionalProperties=false; adapter fallback schemas may
// pass true when the underlying endpoint contract is only partially known.
func ObjectInputSchemaMap(required []string, properties map[string]any, additionalProperties bool) map[string]any {
schema := map[string]any{
"type": "object",
"properties": properties,
"additionalProperties": additionalProperties,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
// StrictObjectInputSchemaMap builds a closed object schema for manifest-owned
// tool arguments.
func StrictObjectInputSchemaMap(required []string, properties map[string]any) map[string]any {
return ObjectInputSchemaMap(required, properties, false)
}
// RawInputSchema marshals a hand-authored JSON Schema map into the raw manifest
// field served by the API and forwarded by adapters.
func RawInputSchema(schema map[string]any) json.RawMessage {
raw, err := json.Marshal(schema)
if err != nil {
panic(err)
}
return raw
}
// CloneRawMessage returns an independent copy of a raw JSON payload.
func CloneRawMessage(raw json.RawMessage) json.RawMessage {
if raw == nil {
return nil
}
return append(json.RawMessage(nil), raw...)
}
// ObjectInputSchema builds a raw JSON Schema object envelope.
func ObjectInputSchema(required []string, properties map[string]any, additionalProperties bool) json.RawMessage {
return RawInputSchema(ObjectInputSchemaMap(required, properties, additionalProperties))
}
// StrictObjectInputSchema builds a closed raw JSON Schema object envelope for
// manifest-owned tool arguments.
func StrictObjectInputSchema(required []string, properties map[string]any) json.RawMessage {
return ObjectInputSchema(required, properties, false)
}
// ProviderInputSchema projects a structured registry input schema into the
// generic JSON Schema map expected by chat providers.
func ProviderInputSchema(schema InputSchema) map[string]interface{} {
schema = schema.NormalizeCollections()
projected := map[string]interface{}{
"type": "object",
"properties": ProviderPropertySchemas(schema.Properties),
"additionalProperties": false,
}
if len(schema.Required) > 0 {
projected["required"] = append([]string(nil), schema.Required...)
}
return projected
}
// ValidateDeclaredToolArguments rejects provider/adapter arguments that are
// not part of the registered tool schema. Internal runtime metadata is
// validated at the provider parse boundary and remains admissible here only so
// the server can replay an operator-approved call without widening the public
// schema.
func ValidateDeclaredToolArguments(schema InputSchema, args map[string]any) error {
schema = schema.NormalizeCollections()
for name := range args {
if IsInternalToolArgument(name) {
continue
}
if _, declared := schema.Properties[name]; !declared {
return fmt.Errorf("undeclared tool argument %q", name)
}
}
for _, name := range schema.Required {
if _, present := args[name]; !present {
return fmt.Errorf("required tool argument %q is missing", name)
}
}
return nil
}
// ProviderInputSchemaFromRaw projects a manifest-authored JSON Schema into the
// provider-facing schema map used by Assistant provider tools. Manifest schemas
// are already JSON Schema object envelopes; this helper keeps legacy Assistant
// callers from copying required-field lists by hand.
func ProviderInputSchemaFromRaw(raw json.RawMessage) map[string]interface{} {
if len(raw) == 0 {
return ProviderInputSchema(InputSchema{})
}
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil || schema == nil {
return ProviderInputSchema(InputSchema{})
}
projected := CloneProviderInputSchema(schema)
if _, ok := projected["type"]; !ok {
projected["type"] = "object"
}
if _, ok := projected["properties"]; !ok {
projected["properties"] = map[string]interface{}{}
}
if required, ok := projected["required"].([]interface{}); ok {
normalized := make([]string, 0, len(required))
allStrings := true
for _, value := range required {
item, ok := value.(string)
if !ok {
allStrings = false
break
}
normalized = append(normalized, item)
}
if allStrings {
projected["required"] = normalized
}
}
return projected
}
// ProjectProviderTool projects one Assistant registry tool into the generic
// provider tool shape used by model clients.
func ProjectProviderTool(tool Tool) ProviderTool {
tool = tool.NormalizeCollections()
return ProviderTool{
Name: tool.Name,
Description: tool.Description,
InputSchema: ProviderInputSchema(tool.InputSchema),
}.NormalizeCollections()
}
// ProjectProviderToolWithGovernance projects one Assistant registry tool into
// the provider-facing shape and appends the shared governance posture to the
// tool description when a matching descriptor is available.
func ProjectProviderToolWithGovernance(tool Tool, governance ToolGovernanceDescriptor) ProviderTool {
projected := ProjectProviderTool(tool)
if !providerToolGovernanceMatches(projected.Name, governance.Name) {
return projected
}
governance = NormalizeToolGovernanceDescriptor(governance)
projected.Description = ProviderToolDescriptionWithGovernance(projected.Description, governance)
projected.BehaviorHints = ToolGovernanceBehaviorHints(governance)
projected.PulseGovernance = &governance
return projected.NormalizeCollections()
}
// ProjectProviderTools projects Assistant registry tools into provider-facing
// tool declarations while preserving order.
func ProjectProviderTools(tools []Tool) []ProviderTool {
projected := make([]ProviderTool, 0, len(tools))
for _, tool := range tools {
projected = append(projected, ProjectProviderTool(tool))
}
return projected
}
// ProjectProviderToolsWithGovernance projects Assistant registry tools into
// provider-facing declarations while attaching the matching registry-owned
// governance descriptor to each description.
func ProjectProviderToolsWithGovernance(tools []Tool, governance []ToolGovernanceDescriptor) []ProviderTool {
governanceByName := make(map[string]ToolGovernanceDescriptor, len(governance))
for _, descriptor := range governance {
name := strings.TrimSpace(descriptor.Name)
if name == "" {
continue
}
governanceByName[name] = descriptor
}
projected := make([]ProviderTool, 0, len(tools))
for _, tool := range tools {
name := strings.TrimSpace(tool.Name)
descriptor, ok := governanceByName[name]
if !ok {
projected = append(projected, ProjectProviderTool(tool))
continue
}
projected = append(projected, ProjectProviderToolWithGovernance(tool, descriptor))
}
return projected
}
// ProjectAssistantProviderTools projects the native Assistant provider tool
// surface from shared Pulse Intelligence contracts. Chat chooses whether the
// current turn is interactive; the shared layer owns how Assistant registry
// tools and Assistant-native interaction tools are composed.
func ProjectAssistantProviderTools(tools []Tool, governance []ToolGovernanceDescriptor, opts AssistantProviderToolOptions) []ProviderTool {
projected := ProjectProviderToolsWithGovernance(tools, governance)
if opts.IncludeQuestionTool {
projected = append(projected, AssistantNativeProviderTools()...)
}
return projected
}
// ProjectPulseAssistantProviderTools projects native Assistant provider tools
// through the manifest-owned Pulse Assistant surface affordances. The runtime
// registry remains the source of available handlers; the manifest decides
// whether the Assistant surface may advertise tools and interactive questions.
func ProjectPulseAssistantProviderTools(manifest Manifest, tools []Tool, governance []ToolGovernanceDescriptor, opts AssistantProviderToolOptions) []ProviderTool {
affordances, _ := ManifestSurfaceAffordances(manifest, SurfaceIDPulseAssistant)
if !affordances.Tools {
return []ProviderTool{}
}
if !affordances.InteractiveQuestions {
opts.IncludeQuestionTool = false
}
return ProjectAssistantProviderTools(tools, governance, opts)
}
// ProviderToolGovernanceDescriptors extracts registry-owned governance from an
// offered Assistant provider-tool list. A nil tool slice means the caller has no
// concrete offered-tool list, so no complete metadata projection can be proven.
// An empty non-nil slice is a valid "no tools offered" manifest.
func ProviderToolGovernanceDescriptors(tools []ProviderTool) ([]ToolGovernanceDescriptor, bool) {
if tools == nil {
return nil, false
}
descriptors := make([]ToolGovernanceDescriptor, 0, len(tools))
seen := make(map[string]bool, len(tools))
for _, tool := range tools {
name := strings.TrimSpace(tool.Name)
if name == "" || seen[name] {
continue
}
seen[name] = true
if name == PulseQuestionToolName {
continue
}
if tool.PulseGovernance == nil {
return nil, false
}
descriptor := NormalizeToolGovernanceDescriptor(*tool.PulseGovernance)
if !providerToolGovernanceMatches(name, descriptor.Name) {
return nil, false
}
descriptors = append(descriptors, descriptor)
}
return descriptors, true
}
// AssistantNativeProviderTools returns provider-facing tools owned by the
// native in-app Assistant surface rather than the registry-backed execution
// tools. These names still belong to the shared provider-tool contract because
// model clients, prompt governance, and leak sanitizers all need the same
// surface vocabulary.
func AssistantNativeProviderTools() []ProviderTool {
return []ProviderTool{NewPulseQuestionProviderTool()}
}
// AssistantNativeProviderToolNames returns the canonical names for native
// Assistant provider tools.
func AssistantNativeProviderToolNames() []string {
return ProviderToolNames(AssistantNativeProviderTools())
}
// ProviderToolNames returns the stable offered-tool name sequence for a
// provider tool list. A nil tool slice remains nil so callers can preserve the
// "all manifest tools" meaning used by prompt projections; an empty non-nil
// slice means no tools are offered.
func ProviderToolNames(tools []ProviderTool) []string {
if tools == nil {
return nil
}
names := make([]string, 0, len(tools))
seen := make(map[string]bool, len(tools))
for _, tool := range tools {
name := strings.TrimSpace(tool.Name)
if name == "" || seen[name] {
continue
}
seen[name] = true
names = append(names, name)
}
return names
}
// ProviderToolNameCatalog is a reusable exact/prefix lookup over provider-tool
// names. It keeps Assistant stream sanitizers and adapter leak guards on the
// same name normalization rules as provider tool projection.
type ProviderToolNameCatalog struct {
names []string
set map[string]struct{}
}
// NewProviderToolNameCatalog builds a detached tool-name catalog from one or
// more ordered name groups. Empty and duplicate names are removed while the
// first-seen order is preserved.
func NewProviderToolNameCatalog(nameGroups ...[]string) ProviderToolNameCatalog {
total := 0
for _, names := range nameGroups {
total += len(names)
}
catalog := ProviderToolNameCatalog{
names: make([]string, 0, total),
set: make(map[string]struct{}, total),
}
for _, names := range nameGroups {
for _, name := range names {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if _, ok := catalog.set[trimmed]; ok {
continue
}
catalog.set[trimmed] = struct{}{}
catalog.names = append(catalog.names, trimmed)
}
}
return catalog
}
// NewAssistantProviderToolNameCatalog adds native Assistant provider tools to
// the registered runtime tool names so leak guards recognize the complete
// Assistant provider surface.
func NewAssistantProviderToolNameCatalog(registryToolNames []string) ProviderToolNameCatalog {
return NewProviderToolNameCatalog(registryToolNames, AssistantNativeProviderToolNames())
}
// Names returns a detached ordered copy of the catalog names.
func (c ProviderToolNameCatalog) Names() []string {
return append([]string(nil), c.names...)
}
// Has reports whether name is in the catalog.
func (c ProviderToolNameCatalog) Has(name string) bool {
if name == "" {
return false
}
_, ok := c.set[name]
return ok
}
// HasPrefix reports whether prefix can still become a catalogued tool name.
func (c ProviderToolNameCatalog) HasPrefix(prefix string) bool {
if prefix == "" {
return false
}
for _, name := range c.names {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// ProviderToolDescriptionWithGovernance returns a provider-facing description
// that keeps the tool's functional description and the canonical governance
// posture adjacent in the same declaration the model sees.
func ProviderToolDescriptionWithGovernance(description string, governance ToolGovernanceDescriptor) string {
governanceDescription := strings.TrimSpace(ToolGovernancePromptDescription(governance))
description = strings.TrimSpace(description)
if governanceDescription == "" {
return description
}
line := "Pulse governance: " + governanceDescription
if description == "" {
return line
}
return description + "\n\n" + line
}
func providerToolGovernanceMatches(toolName, governanceName string) bool {
return strings.TrimSpace(toolName) != "" && strings.TrimSpace(toolName) == strings.TrimSpace(governanceName)
}
// ProviderPropertySchemas projects structured registry properties into provider
// JSON Schema property definitions.
func ProviderPropertySchemas(properties map[string]PropertySchema) map[string]interface{} {
projected := make(map[string]interface{}, len(properties))
for name, property := range properties {
projected[name] = ProviderPropertySchema(property)
}
return projected
}
// ProviderPropertySchema projects one structured registry property into a
// provider JSON Schema property definition.
func ProviderPropertySchema(property PropertySchema) map[string]interface{} {
property = property.NormalizeCollections()
projected := map[string]interface{}{
"type": property.Type,
}
if property.Description != "" {
projected["description"] = property.Description
}
if len(property.Enum) > 0 {
projected["enum"] = append([]string(nil), property.Enum...)
}
if property.Default != nil {
projected["default"] = property.Default
}
return projected
}