Files
ragflow/internal/harness/core/profile/profile.go
Yingfeng 706e0d2d06 Refactor harness framework (#16271)
### What problem does this PR solve?

- Tools management
- Pregel engine wrapper for better usage
- UT race
- Coding style

### Type of change

- [x] Refactoring
2026-06-23 20:18:04 +08:00

439 lines
14 KiB
Go

// Package profile provides a dual-track configuration system for agentcore:
//
// - ProviderProfile: controls how an LLM model is constructed per provider
// (api_key, temperature, max_tokens, api_base, use_responses_api, etc.)
// - HarnessProfile: controls the agent's runtime behaviour per use-case
// (system prompt, tool descriptions, middleware exclusions, recursion depth)
//
// Usage:
//
// // Register once at init time.
// profile.RegisterProvider("anthropic", &profile.ProviderProfile{
// InitModel: func(ctx, modelName string, opts map[string]any) (Model, error) {
// return anthropic.NewModel(modelName, opts["api_key"].(string)), nil
// },
// DefaultModel: "claude-sonnet-4-6",
// })
// profile.RegisterHarness("coding-agent", &profile.HarnessProfile{
// BaseSystemPrompt: strPtr("You are an expert software engineer."),
// MaxIterations: 20,
// RecursionDepth: 5,
// })
//
// // Create an agent in one call.
// agent, err := profile.NewAgent(ctx, &profile.AgentConfig{
// ModelSpec: "anthropic:claude-sonnet-4-6",
// HarnessProfileName: "coding-agent",
// Tools: []core.Tool{myTool},
// })
//
// Config precedence (highest wins): user > HarnessProfile > ProviderProfile > defaults.
package profile
import (
"context"
"fmt"
"strings"
"sync"
"ragflow/internal/harness/core"
"ragflow/internal/harness/core/internal"
"ragflow/internal/harness/core/middlewares/subagent"
"ragflow/internal/harness/core/schema"
)
// ========================================================================
// Phase 1: Core types + global registry
// ========================================================================
// ProviderProfile controls how a model is constructed for a given provider.
// Each provider (Anthropic, OpenAI, Google, …) registers one ProviderProfile
// that knows how to build a concrete Model from a model name + options.
type ProviderProfile struct {
// Name identifies the provider, e.g. "anthropic", "openai".
Name string
// InitModel creates a Model instance for the given model name and options.
// opts typically carries "api_key", "temperature", "max_tokens", "api_base", etc.
InitModel func(ctx context.Context, modelName string, opts map[string]any) (core.Model[*schema.Message], error)
// DefaultModel is returned when no model name is specified.
DefaultModel string
// DefaultOpts are the default options passed to InitModel.
// Can be overridden by HarnessProfile or user config.
DefaultOpts map[string]any
}
// HarnessProfile controls the agent's runtime behaviour for a specific use-case.
// The same model can be used with different harness profiles (coding, chat, research, …).
type HarnessProfile struct {
// Name identifies the profile, e.g. "coding-agent", "research-agent".
Name string
// BaseSystemPrompt replaces the default system prompt entirely.
// When nil, the system default is used.
BaseSystemPrompt *string
// SystemPromptSuffix is appended to the system prompt after BaseSystemPrompt.
SystemPromptSuffix string
// ToolDescriptionOverrides replaces the Description() of matching tools.
// Key = tool name, value = new description.
ToolDescriptionOverrides map[string]string
// ExcludedToolNames removes matching tools from the agent's tool list.
ExcludedToolNames []string
// ExcludedMiddlewareNames removes matching middlewares from the agent's
// middleware chain. Matching uses fmt.Sprintf("%T", mw) — include the
// fully qualified type name, e.g. "*subagent.SubAgentMiddleware".
ExcludedMiddlewareNames []string
// ExtraMiddlewares are appended to the agent's middleware chain at the end.
ExtraMiddlewares []core.ReActMiddleware
// MaxIterations overrides the ReAct loop iteration limit. 0 = use default.
MaxIterations int
// RecursionDepth sets the sub-agent recursion depth limit.
// 0 = unlimited (system default).
RecursionDepth int
}
// Global registries.
var (
providers sync.Map // map[string]*ProviderProfile
harnesses sync.Map // map[string]*HarnessProfile
)
// RegisterProvider registers a provider profile. Panics on duplicate name.
func RegisterProvider(p *ProviderProfile) {
if p == nil {
panic("profile: RegisterProvider called with nil")
}
if p.Name == "" {
panic("profile: ProviderProfile.Name is required")
}
if _, loaded := providers.LoadOrStore(p.Name, p); loaded {
panic(fmt.Sprintf("profile: provider %q already registered", p.Name))
}
}
// RegisterHarness registers a harness profile. Panics on duplicate name.
func RegisterHarness(h *HarnessProfile) {
if h == nil {
panic("profile: RegisterHarness called with nil")
}
if h.Name == "" {
panic("profile: HarnessProfile.Name is required")
}
if _, loaded := harnesses.LoadOrStore(h.Name, h); loaded {
panic(fmt.Sprintf("profile: harness profile %q already registered", h.Name))
}
}
// LookupProvider returns the registered provider, or nil if not found.
func LookupProvider(name string) *ProviderProfile {
if v, ok := providers.Load(name); ok {
return v.(*ProviderProfile)
}
return nil
}
// LookupHarness returns the registered harness profile, or nil if not found.
func LookupHarness(name string) *HarnessProfile {
if v, ok := harnesses.Load(name); ok {
return v.(*HarnessProfile)
}
return nil
}
// ParseModelSpec parses "provider:model" strings.
// Returns ("anthropic", "claude-sonnet-4-6") from "anthropic:claude-sonnet-4-6".
// If no colon, returns ("", raw, error).
func ParseModelSpec(spec string) (provider, model string, err error) {
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("profile: invalid model spec %q (expected provider:model)", spec)
}
return parts[0], parts[1], nil
}
// ========================================================================
// AgentConfig combines model spec + harness profile + user overrides.
// ========================================================================
// AgentConfig is a high-level declarative config for creating a ReActAgent.
// It combines model selection (via ModelSpec), runtime behaviour (via
// HarnessProfileName), and direct user overrides.
type AgentConfig struct {
// ModelSpec in "provider:model" format, e.g. "anthropic:claude-sonnet-4-6".
ModelSpec string
// HarnessProfileName selects a registered HarnessProfile.
// Empty string means no harness profile.
HarnessProfileName string
// ProviderOpts overrides or augments the ProviderProfile's DefaultOpts.
ProviderOpts map[string]any
// Instruction overrides the system prompt. When non-nil, it takes
// precedence over both HarnessProfile.BaseSystemPrompt and the system default.
Instruction *string
// Tools available to the agent. When non-nil, replaces all previous tool lists.
Tools []core.Tool
// Middlewares to apply. Appended after HarnessProfile.ExtraMiddlewares.
Middlewares []core.ReActMiddleware
// MaxIterations overrides both HarnessProfile and system default.
MaxIterations int
// SubAgentSpecs declares sub-agents. The SubAgentMiddleware is automatically
// created with recursion depth from the active HarnessProfile.
SubAgentSpecs []subagent.SubAgentSpec
}
// ========================================================================
// Phase 3: Override chain with precedence rules
// ========================================================================
// NewAgent creates a ReActAgent from a declarative AgentConfig.
//
// Precedence (highest wins): user explicit > HarnessProfile > ProviderProfile > defaults.
func NewAgent(ctx context.Context, cfg *AgentConfig) (core.Agent, error) {
if cfg == nil {
return nil, fmt.Errorf("profile: AgentConfig is nil")
}
// 1. Resolve model from ModelSpec.
model, err := buildModel(ctx, cfg)
if err != nil {
return nil, err
}
// 2. Build ReActConfig via override chain.
reactCfg := buildReactConfig(ctx, cfg)
// 3. Set the resolved model.
reactCfg.Model = model
// 4. Handle HarnessProfile's ExcludedToolNames.
if harness := lookupHarness(cfg.HarnessProfileName); harness != nil && len(harness.ExcludedToolNames) > 0 {
excluded := makeMap(harness.ExcludedToolNames)
filtered := make([]core.Tool, 0, len(reactCfg.Tools))
for _, t := range reactCfg.Tools {
if excluded[t.Name()] {
continue
}
filtered = append(filtered, t)
}
reactCfg.Tools = filtered
}
// 5. Apply tool description overrides (wrap matching tools).
if harness := lookupHarness(cfg.HarnessProfileName); harness != nil && len(harness.ToolDescriptionOverrides) > 0 {
for i, t := range reactCfg.Tools {
if newDesc, ok := harness.ToolDescriptionOverrides[t.Name()]; ok && newDesc != "" {
reactCfg.Tools[i] = &descriptionOverrideTool{Tool: t, newDesc: newDesc}
}
}
}
// 6. Handle SubAgentSpecs — create SubAgentMiddleware and init.
if len(cfg.SubAgentSpecs) > 0 {
subCfg := &subagent.Config{}
if harness := lookupHarness(cfg.HarnessProfileName); harness != nil && harness.RecursionDepth > 0 {
subCfg.MaxDepth = harness.RecursionDepth
}
saMW := subagent.New(cfg.SubAgentSpecs, subCfg)
reactCfg.Middlewares = append(reactCfg.Middlewares, saMW)
saMW.Init(ctx, reactCfg)
}
return core.NewReActAgent(reactCfg), nil
}
// buildModel resolves the model from ModelSpec + ProviderProfile.
func buildModel(ctx context.Context, cfg *AgentConfig) (core.Model[*schema.Message], error) {
providerName, modelName, err := ParseModelSpec(cfg.ModelSpec)
if err != nil {
return nil, err
}
provider := LookupProvider(providerName)
if provider == nil {
return nil, fmt.Errorf("profile: unknown provider %q (registered: %s)", providerName, listProviders())
}
// Merge options: DefaultOpts ← ProviderOpts.
opts := copyMap(provider.DefaultOpts)
for k, v := range cfg.ProviderOpts {
opts[k] = v
}
m, err := provider.InitModel(ctx, modelName, opts)
if err != nil {
return nil, fmt.Errorf("profile: InitModel(%s, %s): %w", providerName, modelName, err)
}
return m, nil
}
// buildReactConfig applies the override chain for non-model config fields.
func buildReactConfig(ctx context.Context, cfg *AgentConfig) *core.ReActConfig[*schema.Message] {
// Start with system defaults.
result := &core.ReActConfig[*schema.Message]{
MaxIterations: 10,
Instruction: internal.DefaultSystemPrompt,
}
harness := lookupHarness(cfg.HarnessProfileName)
// Layer 1: HarnessProfile.
if harness != nil {
if harness.MaxIterations > 0 {
result.MaxIterations = harness.MaxIterations
}
if harness.BaseSystemPrompt != nil {
result.Instruction = *harness.BaseSystemPrompt
}
result.Instruction += harness.SystemPromptSuffix
// Extra middlewares (appended; ExcludedMiddlewareNames applied later).
result.Middlewares = append(result.Middlewares, harness.ExtraMiddlewares...)
}
// Layer 2: User explicit config (highest priority).
if cfg.Instruction != nil {
result.Instruction = *cfg.Instruction
}
if cfg.Tools != nil {
result.Tools = cfg.Tools
}
if cfg.MaxIterations > 0 {
result.MaxIterations = cfg.MaxIterations
}
if cfg.Middlewares != nil {
result.Middlewares = append(result.Middlewares, cfg.Middlewares...)
}
// Apply ExcludedMiddlewareNames from HarnessProfile.
if harness != nil && len(harness.ExcludedMiddlewareNames) > 0 {
result.Middlewares = filterMiddlewareByTypeName(result.Middlewares, harness.ExcludedMiddlewareNames)
}
return result
}
// ========================================================================
// Helpers
// ========================================================================
func lookupHarness(name string) *HarnessProfile {
if name == "" {
return nil
}
return LookupHarness(name)
}
func listProviders() string {
var names []string
providers.Range(func(key, _ any) bool {
names = append(names, key.(string))
return true
})
return strings.Join(names, ", ")
}
func copyMap(src map[string]any) map[string]any {
if src == nil {
return make(map[string]any)
}
dst := make(map[string]any, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func makeMap(keys []string) map[string]bool {
m := make(map[string]bool, len(keys))
for _, k := range keys {
m[k] = true
}
return m
}
// filterMiddlewareByTypeName removes middlewares whose fmt.Sprintf("%T") matches
// any name in the exclusion list.
func filterMiddlewareByTypeName(mws []core.ReActMiddleware, exclude []string) []core.ReActMiddleware {
if len(exclude) == 0 {
return mws
}
excluded := makeMap(exclude)
filtered := make([]core.ReActMiddleware, 0, len(mws))
for _, mw := range mws {
if mw == nil {
continue
}
typeName := fmt.Sprintf("%T", mw)
if excluded[typeName] {
continue
}
filtered = append(filtered, mw)
}
return filtered
}
// descriptionOverrideTool wraps a Tool to override its Description().
type descriptionOverrideTool struct {
core.Tool
newDesc string
}
func (t *descriptionOverrideTool) Description() string { return t.newDesc }
// StrPtr is a helper for creating *string literals.
func StrPtr(s string) *string { return &s }
// Validate checks the AgentConfig for common errors and returns them all at once.
func Validate(cfg *AgentConfig) []error {
var errs []error
if cfg == nil {
return []error{fmt.Errorf("profile: AgentConfig is nil")}
}
if cfg.ModelSpec == "" {
errs = append(errs, fmt.Errorf("profile: ModelSpec is required"))
} else if _, _, err := ParseModelSpec(cfg.ModelSpec); err != nil {
errs = append(errs, err)
}
if cfg.HarnessProfileName != "" && LookupHarness(cfg.HarnessProfileName) == nil {
errs = append(errs, fmt.Errorf("profile: harness profile %q not found", cfg.HarnessProfileName))
}
return errs
}
// ClearProviders removes all registered providers. Used in tests for isolation.
func ClearProviders() {
providers = sync.Map{}
}
// ClearHarnesses removes all registered harness profiles. Used in tests.
func ClearHarnesses() {
harnesses = sync.Map{}
}
// RegisterProviderModel is a convenience wrapper that registers both a provider
// profile (using ProviderProfile.Name) AND a harness profile for each supported
// model. This matches deepagents' double-registration pattern.
//
// Deprecated: Use separate RegisterProvider and RegisterHarness calls instead.
func RegisterProviderModel(provider *ProviderProfile, harnessProfiles ...*HarnessProfile) {
RegisterProvider(provider)
for _, h := range harnessProfiles {
RegisterHarness(h)
}
}