mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-22 23:43:30 +00:00
1062 lines
38 KiB
Go
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
|
|
}
|