Give each tracker agent its own config

The tracker-mapping, common-pattern enrichment, and third-party
disambiguation agents were all built from one shared
TrackerAgentsConfig fed by a single tracker-mapping config slot. That
forced a single AgentTimeout to be reused and patched per worker, and
two unrelated max-turns fields to share one struct.

Split the in-code config into TrackerMappingAgentConfig,
TrackerEnrichmentAgentConfig, and DisambiguationAgentConfig, each with
its own timeout and max-turns, and add dedicated tracker-enrichment and
third-party-disambiguation provider slots (the latter resolving next to
third-party-vetter). Enrichment and disambiguation fall back to the
tracker-mapping slot when their own provider is unset, preserving
single-config deployments.

Drop the shared pkg/agentsbuild package and duplicate its small wiring
into probod and proboctl so the two executables stay decoupled. Wire
the new env vars, builder test coverage, and Helm values.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-08 11:24:37 +02:00
parent e6866f88a0
commit 92a73e9302
20 changed files with 552 additions and 289 deletions

View File

@@ -281,6 +281,23 @@ spec:
- name: THIRD_PARTY_VETTING_STALE_AFTER - name: THIRD_PARTY_VETTING_STALE_AFTER
value: {{ .Values.probo.thirdPartyVettingWorker.staleAfter | quote }} value: {{ .Values.probo.thirdPartyVettingWorker.staleAfter | quote }}
{{- end }} {{- end }}
# Third-party Disambiguation Agent
{{- if .Values.probo.thirdPartyDisambiguation.provider }}
- name: AGENT_THIRD_PARTY_DISAMBIGUATION_PROVIDER
value: {{ .Values.probo.thirdPartyDisambiguation.provider | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyDisambiguation.modelName }}
- name: AGENT_THIRD_PARTY_DISAMBIGUATION_MODEL_NAME
value: {{ .Values.probo.thirdPartyDisambiguation.modelName | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyDisambiguation.temperature }}
- name: AGENT_THIRD_PARTY_DISAMBIGUATION_TEMPERATURE
value: {{ .Values.probo.thirdPartyDisambiguation.temperature | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyDisambiguation.maxTokens }}
- name: AGENT_THIRD_PARTY_DISAMBIGUATION_MAX_TOKENS
value: {{ .Values.probo.thirdPartyDisambiguation.maxTokens | quote }}
{{- end }}
# Tracker Mapping Agent # Tracker Mapping Agent
{{- if .Values.probo.trackerMapping.provider }} {{- if .Values.probo.trackerMapping.provider }}
- name: AGENT_TRACKER_MAPPING_PROVIDER - name: AGENT_TRACKER_MAPPING_PROVIDER
@@ -298,6 +315,23 @@ spec:
- name: AGENT_TRACKER_MAPPING_MAX_TOKENS - name: AGENT_TRACKER_MAPPING_MAX_TOKENS
value: {{ .Values.probo.trackerMapping.maxTokens | quote }} value: {{ .Values.probo.trackerMapping.maxTokens | quote }}
{{- end }} {{- end }}
# Tracker Enrichment Agent
{{- if .Values.probo.trackerEnrichment.provider }}
- name: AGENT_TRACKER_ENRICHMENT_PROVIDER
value: {{ .Values.probo.trackerEnrichment.provider | quote }}
{{- end }}
{{- if .Values.probo.trackerEnrichment.modelName }}
- name: AGENT_TRACKER_ENRICHMENT_MODEL_NAME
value: {{ .Values.probo.trackerEnrichment.modelName | quote }}
{{- end }}
{{- if .Values.probo.trackerEnrichment.temperature }}
- name: AGENT_TRACKER_ENRICHMENT_TEMPERATURE
value: {{ .Values.probo.trackerEnrichment.temperature | quote }}
{{- end }}
{{- if .Values.probo.trackerEnrichment.maxTokens }}
- name: AGENT_TRACKER_ENRICHMENT_MAX_TOKENS
value: {{ .Values.probo.trackerEnrichment.maxTokens | quote }}
{{- end }}
# Tracker Mapping Worker # Tracker Mapping Worker
{{- if .Values.probo.trackerMappingWorker.interval }} {{- if .Values.probo.trackerMappingWorker.interval }}
- name: TRACKER_MAPPING_INTERVAL - name: TRACKER_MAPPING_INTERVAL
@@ -319,6 +353,10 @@ spec:
- name: TRACKER_MAPPING_AGENT_MAX_TURNS - name: TRACKER_MAPPING_AGENT_MAX_TURNS
value: {{ .Values.probo.trackerMappingWorker.agentMaxTurns | quote }} value: {{ .Values.probo.trackerMappingWorker.agentMaxTurns | quote }}
{{- end }} {{- end }}
{{- if .Values.probo.trackerMappingWorker.disambiguationAgentTimeout }}
- name: TRACKER_MAPPING_DISAMBIGUATION_AGENT_TIMEOUT
value: {{ .Values.probo.trackerMappingWorker.disambiguationAgentTimeout | quote }}
{{- end }}
# Common Pattern Enrichment Worker # Common Pattern Enrichment Worker
{{- if .Values.probo.commonPatternEnrichmentWorker.interval }} {{- if .Values.probo.commonPatternEnrichmentWorker.interval }}
- name: COMMON_PATTERN_ENRICHMENT_INTERVAL - name: COMMON_PATTERN_ENRICHMENT_INTERVAL

View File

@@ -185,6 +185,15 @@ probo:
# maxConcurrency: 1 # maxConcurrency: 1
# staleAfter: 1500 # staleAfter: 1500
# Third-party disambiguation agent (optional; runs inside the tracker
# mapping worker to pick the best matching org third party). Falls back
# to trackerMapping when its provider is unset.
# thirdPartyDisambiguation:
# provider: "openai"
# modelName: "gpt-4o"
# temperature: "0.4"
# maxTokens: "4096"
# Tracker mapping agent (optional, auto-links tracker patterns to vendors) # Tracker mapping agent (optional, auto-links tracker patterns to vendors)
# trackerMapping: # trackerMapping:
# provider: "openai" # provider: "openai"
@@ -192,8 +201,16 @@ probo:
# temperature: "0.1" # temperature: "0.1"
# maxTokens: "4096" # maxTokens: "4096"
# Tracker enrichment agent (optional; researches catalog descriptions).
# Falls back to trackerMapping when its provider is unset.
# trackerEnrichment:
# provider: "openai"
# modelName: "gpt-4o"
# temperature: "0.2"
# maxTokens: "4096"
# Tracker mapping worker tuning (optional; seconds for # Tracker mapping worker tuning (optional; seconds for
# interval/staleAfter/agentTimeout). # interval/staleAfter/agentTimeout/disambiguationAgentTimeout).
# Keep concurrency modest to stay under OpenAI/Firecrawl limits and the DB pool. # Keep concurrency modest to stay under OpenAI/Firecrawl limits and the DB pool.
# trackerMappingWorker: # trackerMappingWorker:
# interval: 10 # interval: 10
@@ -201,6 +218,7 @@ probo:
# staleAfter: 600 # staleAfter: 600
# agentTimeout: 45 # agentTimeout: 45
# agentMaxTurns: 10 # agentMaxTurns: 10
# disambiguationAgentTimeout: 45
# Common-pattern enrichment worker tuning (optional; seconds for # Common-pattern enrichment worker tuning (optional; seconds for
# interval/staleAfter/agentTimeout). # interval/staleAfter/agentTimeout).

View File

@@ -286,6 +286,15 @@ probo:
maxConcurrency: 1 maxConcurrency: 1
staleAfter: 1500 staleAfter: 1500
# Third-party disambiguation agent (optional; runs inside the tracker
# mapping worker to pick the best matching org third party). Falls back
# to trackerMapping when its provider is unset.
thirdPartyDisambiguation:
provider: ""
modelName: ""
temperature: ""
maxTokens: ""
# Tracker mapping agent (optional, requires openai.apiKey or anthropic key) # Tracker mapping agent (optional, requires openai.apiKey or anthropic key)
trackerMapping: trackerMapping:
provider: "" provider: ""
@@ -293,16 +302,25 @@ probo:
temperature: "" temperature: ""
maxTokens: "" maxTokens: ""
# Tracker enrichment agent (optional; researches catalog descriptions).
# Falls back to trackerMapping when its provider is unset.
trackerEnrichment:
provider: ""
modelName: ""
temperature: ""
maxTokens: ""
# Tracker mapping background worker tuning (optional). interval, # Tracker mapping background worker tuning (optional). interval,
# staleAfter, and agentTimeout are in seconds. Keep concurrency modest # staleAfter, agentTimeout, and disambiguationAgentTimeout are in
# to stay under OpenAI/Firecrawl rate limits and the database # seconds. Keep concurrency modest to stay under OpenAI/Firecrawl rate
# connection pool. # limits and the database connection pool.
trackerMappingWorker: trackerMappingWorker:
interval: 10 interval: 10
maxConcurrency: 3 maxConcurrency: 3
staleAfter: 600 staleAfter: 600
agentTimeout: 45 agentTimeout: 45
agentMaxTurns: 10 agentMaxTurns: 10
disambiguationAgentTimeout: 45
# Common-pattern enrichment background worker tuning (optional). # Common-pattern enrichment background worker tuning (optional).
# interval, staleAfter, and agentTimeout are in seconds. # interval, staleAfter, and agentTimeout are in seconds.

View File

@@ -1,172 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
// Package agentsbuild builds LLM clients and tracker-agent configuration
// from the shared probodconfig types. It is the single wiring used by
// both probod (background workers) and proboctl (synchronous operator
// commands) so the two executables build agents identically.
package agentsbuild
import (
"fmt"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
"go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/llm"
llmanthropic "go.probo.inc/probo/pkg/llm/anthropic"
llmopenai "go.probo.inc/probo/pkg/llm/openai"
"go.probo.inc/probo/pkg/probodconfig"
"go.probo.inc/probo/pkg/thirdparty"
)
// BuildLLMClient creates an LLM client for the given provider config.
func BuildLLMClient(
cfg probodconfig.LLMProviderConfig,
l *log.Logger,
tp trace.TracerProvider,
r prometheus.Registerer,
) (*llm.Client, error) {
providerType := cfg.Type
if providerType == "" {
providerType = "openai"
}
httpClient := httpclient.DefaultPooledClient(
httpclient.WithLogger(l),
httpclient.WithTracerProvider(tp),
httpclient.WithRegisterer(r),
)
switch providerType {
case "openai":
p := llmopenai.NewProvider(
cfg.APIKey,
llmopenai.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"openai",
llm.WithLogger(l),
llm.WithTracerProvider(tp),
), nil
case "anthropic":
p := llmanthropic.NewProvider(
cfg.APIKey,
llmanthropic.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"anthropic",
llm.WithLogger(l),
llm.WithTracerProvider(tp),
), nil
case "bedrock":
return nil, fmt.Errorf("bedrock provider not yet wired; requires aws.Config")
default:
return nil, fmt.Errorf("unsupported LLM provider type: %q", providerType)
}
}
// ResolveAgentClient resolves an agent's effective config from defaults
// and builds an LLM client for it. The name is used in the logger name
// and error messages.
func ResolveAgentClient(
agents probodconfig.AgentsConfig,
name string,
agent probodconfig.LLMAgentConfig,
l *log.Logger,
tp trace.TracerProvider,
r prometheus.Registerer,
) (probodconfig.LLMAgentConfig, *llm.Client, error) {
resolved := agents.ResolveAgent(agent)
providerCfg, ok := agents.Providers[resolved.Provider]
if !ok {
return probodconfig.LLMAgentConfig{}, nil, fmt.Errorf("unknown LLM provider %q for %s agent", resolved.Provider, name)
}
client, err := BuildLLMClient(providerCfg, l.Named("llm."+name), tp, r)
if err != nil {
return probodconfig.LLMAgentConfig{}, nil, fmt.Errorf("cannot create %s LLM client: %w", name, err)
}
return resolved, client, nil
}
// BuildTrackerAgentsConfig wires the tracker agents that share one LLM
// client and model: the tracker-mapping agent (catalog identification),
// the common-pattern enrichment agent (description research), and the
// third-party disambiguation agent. All are opt-in: when
// `llm.tracker-mapping.provider` is empty it returns zero configs (nil
// LLM client) so callers run without agent fallback.
func BuildTrackerAgentsConfig(
cfg probodconfig.Config,
l *log.Logger,
tp trace.TracerProvider,
r prometheus.Registerer,
) (cookiebanner.TrackerAgentsConfig, thirdparty.DisambiguationConfig, error) {
if cfg.Agents.TrackerMapping.Provider == "" {
return cookiebanner.TrackerAgentsConfig{}, thirdparty.DisambiguationConfig{}, nil
}
agentCfg, llmClient, err := ResolveAgentClient(
cfg.Agents,
"tracker-mapping",
cfg.Agents.TrackerMapping,
l,
tp,
r,
)
if err != nil {
return cookiebanner.TrackerAgentsConfig{}, thirdparty.DisambiguationConfig{}, fmt.Errorf("cannot resolve tracker mapping agent client: %w", err)
}
mappingWorkerCfg := cfg.TrackerMappingWorker
enrichmentWorkerCfg := cfg.CommonPatternEnrichmentWorker
// The mapping and enrichment agents share one config slot but run
// from separate workers with separate max-turns. AgentTimeout here
// carries the mapping worker's value (also reused by the
// disambiguation agent); the enrichment worker overrides it on its
// own copy at registration.
trackerAgentsCfg := cookiebanner.TrackerAgentsConfig{
LLMClient: llmClient,
Model: agentCfg.ModelName,
FirecrawlAPIKey: cfg.Agents.Tools.FirecrawlAPIKey,
MaxTokens: agentCfg.MaxTokens,
Temperature: agentCfg.Temperature,
AgentTimeout: time.Duration(mappingWorkerCfg.AgentTimeout) * time.Second,
MappingMaxTurns: mappingWorkerCfg.AgentMaxTurns,
EnrichmentMaxTurns: enrichmentWorkerCfg.AgentMaxTurns,
}
// The disambiguation agent emits a single id plus a short rationale,
// so it keeps its own smaller token budget (left unset here) rather
// than inheriting the mapping agent's. It shares the mapping worker's
// timeout.
disambiguationCfg := thirdparty.DisambiguationConfig{
LLMClient: llmClient,
Model: agentCfg.ModelName,
Temperature: agentCfg.Temperature,
Timeout: time.Duration(mappingWorkerCfg.AgentTimeout) * time.Second,
}
return trackerAgentsCfg, disambiguationCfg, nil
}

View File

@@ -213,6 +213,17 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
Temperature: b.getEnvFloatPtr("AGENT_THIRD_PARTY_VETTER_TEMPERATURE"), Temperature: b.getEnvFloatPtr("AGENT_THIRD_PARTY_VETTER_TEMPERATURE"),
MaxTokens: b.getEnvIntPtr("AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"), MaxTokens: b.getEnvIntPtr("AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"),
}, },
ThirdPartyDisambiguation: probodconfig.LLMAgentConfig{
Provider: b.getEnvOrDefault("AGENT_THIRD_PARTY_DISAMBIGUATION_PROVIDER", ""),
ModelName: b.getEnvOrDefault("AGENT_THIRD_PARTY_DISAMBIGUATION_MODEL_NAME", ""),
// The disambiguation agent emits a single id plus a
// short rationale, but the budget must leave headroom
// for reasoning models whose reasoning tokens count
// against max_tokens; too small a budget truncates the
// JSON.
Temperature: b.getEnvFloatPtr("AGENT_THIRD_PARTY_DISAMBIGUATION_TEMPERATURE"),
MaxTokens: new(b.getEnvIntOrDefault("AGENT_THIRD_PARTY_DISAMBIGUATION_MAX_TOKENS", 4096)),
},
TrackerMapping: probodconfig.LLMAgentConfig{ TrackerMapping: probodconfig.LLMAgentConfig{
Provider: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_PROVIDER", ""), Provider: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_PROVIDER", ""),
ModelName: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_MODEL_NAME", ""), ModelName: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_MODEL_NAME", ""),
@@ -223,6 +234,14 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
Temperature: b.getEnvFloatPtr("AGENT_TRACKER_MAPPING_TEMPERATURE"), Temperature: b.getEnvFloatPtr("AGENT_TRACKER_MAPPING_TEMPERATURE"),
MaxTokens: new(b.getEnvIntOrDefault("AGENT_TRACKER_MAPPING_MAX_TOKENS", 4096)), MaxTokens: new(b.getEnvIntOrDefault("AGENT_TRACKER_MAPPING_MAX_TOKENS", 4096)),
}, },
TrackerEnrichment: probodconfig.LLMAgentConfig{
Provider: b.getEnvOrDefault("AGENT_TRACKER_ENRICHMENT_PROVIDER", ""),
ModelName: b.getEnvOrDefault("AGENT_TRACKER_ENRICHMENT_MODEL_NAME", ""),
// See the tracker-mapping note: keep ample headroom so
// reasoning models do not truncate the structured JSON.
Temperature: b.getEnvFloatPtr("AGENT_TRACKER_ENRICHMENT_TEMPERATURE"),
MaxTokens: new(b.getEnvIntOrDefault("AGENT_TRACKER_ENRICHMENT_MAX_TOKENS", 4096)),
},
Tools: probodconfig.AgentToolsConfig{ Tools: probodconfig.AgentToolsConfig{
FirecrawlAPIKey: b.getEnv("FIRECRAWL_API_KEY"), FirecrawlAPIKey: b.getEnv("FIRECRAWL_API_KEY"),
}, },
@@ -259,11 +278,12 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
MaxConcurrency: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_MAX_CONCURRENCY", 1), MaxConcurrency: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_MAX_CONCURRENCY", 1),
}, },
TrackerMappingWorker: probodconfig.TrackerMappingWorkerConfig{ TrackerMappingWorker: probodconfig.TrackerMappingWorkerConfig{
Interval: b.getEnvIntOrDefault("TRACKER_MAPPING_INTERVAL", 10), Interval: b.getEnvIntOrDefault("TRACKER_MAPPING_INTERVAL", 10),
MaxConcurrency: b.getEnvIntOrDefault("TRACKER_MAPPING_MAX_CONCURRENCY", 3), MaxConcurrency: b.getEnvIntOrDefault("TRACKER_MAPPING_MAX_CONCURRENCY", 3),
StaleAfter: b.getEnvIntOrDefault("TRACKER_MAPPING_STALE_AFTER", 600), StaleAfter: b.getEnvIntOrDefault("TRACKER_MAPPING_STALE_AFTER", 600),
AgentTimeout: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_TIMEOUT", 45), AgentTimeout: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_TIMEOUT", 45),
AgentMaxTurns: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_MAX_TURNS", 10), AgentMaxTurns: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_MAX_TURNS", 10),
DisambiguationAgentTimeout: b.getEnvIntOrDefault("TRACKER_MAPPING_DISAMBIGUATION_AGENT_TIMEOUT", 45),
}, },
CommonPatternEnrichmentWorker: probodconfig.CommonPatternEnrichmentWorkerConfig{ CommonPatternEnrichmentWorker: probodconfig.CommonPatternEnrichmentWorkerConfig{
Interval: b.getEnvIntOrDefault("COMMON_PATTERN_ENRICHMENT_INTERVAL", 10), Interval: b.getEnvIntOrDefault("COMMON_PATTERN_ENRICHMENT_INTERVAL", 10),

View File

@@ -220,10 +220,18 @@ func TestBuilder_Build_Defaults(t *testing.T) {
assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.ModelName) assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.ModelName)
assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.Temperature) assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.Temperature)
assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.MaxTokens) assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.MaxTokens)
assert.Empty(t, cfg.Probod.Agents.ThirdPartyDisambiguation.Provider)
assert.Empty(t, cfg.Probod.Agents.ThirdPartyDisambiguation.ModelName)
assert.Nil(t, cfg.Probod.Agents.ThirdPartyDisambiguation.Temperature)
assert.Equal(t, new(4096), cfg.Probod.Agents.ThirdPartyDisambiguation.MaxTokens)
assert.Empty(t, cfg.Probod.Agents.TrackerMapping.Provider) assert.Empty(t, cfg.Probod.Agents.TrackerMapping.Provider)
assert.Empty(t, cfg.Probod.Agents.TrackerMapping.ModelName) assert.Empty(t, cfg.Probod.Agents.TrackerMapping.ModelName)
assert.Nil(t, cfg.Probod.Agents.TrackerMapping.Temperature) assert.Nil(t, cfg.Probod.Agents.TrackerMapping.Temperature)
assert.Equal(t, new(4096), cfg.Probod.Agents.TrackerMapping.MaxTokens) assert.Equal(t, new(4096), cfg.Probod.Agents.TrackerMapping.MaxTokens)
assert.Empty(t, cfg.Probod.Agents.TrackerEnrichment.Provider)
assert.Empty(t, cfg.Probod.Agents.TrackerEnrichment.ModelName)
assert.Nil(t, cfg.Probod.Agents.TrackerEnrichment.Temperature)
assert.Equal(t, new(4096), cfg.Probod.Agents.TrackerEnrichment.MaxTokens)
// Tracker worker tuning — defaults // Tracker worker tuning — defaults
assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.Interval) assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.Interval)
@@ -231,6 +239,7 @@ func TestBuilder_Build_Defaults(t *testing.T) {
assert.Equal(t, 600, cfg.Probod.TrackerMappingWorker.StaleAfter) assert.Equal(t, 600, cfg.Probod.TrackerMappingWorker.StaleAfter)
assert.Equal(t, 45, cfg.Probod.TrackerMappingWorker.AgentTimeout) assert.Equal(t, 45, cfg.Probod.TrackerMappingWorker.AgentTimeout)
assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.AgentMaxTurns) assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.AgentMaxTurns)
assert.Equal(t, 45, cfg.Probod.TrackerMappingWorker.DisambiguationAgentTimeout)
assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.Interval) assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.Interval)
assert.Equal(t, 2, cfg.Probod.CommonPatternEnrichmentWorker.MaxConcurrency) assert.Equal(t, 2, cfg.Probod.CommonPatternEnrichmentWorker.MaxConcurrency)
assert.Equal(t, 600, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter) assert.Equal(t, 600, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter)
@@ -333,17 +342,28 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
env["AGENT_THIRD_PARTY_VETTER_MODEL_NAME"] = "gpt-4o" env["AGENT_THIRD_PARTY_VETTER_MODEL_NAME"] = "gpt-4o"
env["AGENT_THIRD_PARTY_VETTER_TEMPERATURE"] = "0.3" env["AGENT_THIRD_PARTY_VETTER_TEMPERATURE"] = "0.3"
env["AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"] = "8192" env["AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"] = "8192"
// Agents — third-party-disambiguation override
env["AGENT_THIRD_PARTY_DISAMBIGUATION_PROVIDER"] = "anthropic"
env["AGENT_THIRD_PARTY_DISAMBIGUATION_MODEL_NAME"] = "claude-sonnet-4-20250514"
env["AGENT_THIRD_PARTY_DISAMBIGUATION_TEMPERATURE"] = "0.4"
env["AGENT_THIRD_PARTY_DISAMBIGUATION_MAX_TOKENS"] = "2048"
// Agents — tracker-mapping override // Agents — tracker-mapping override
env["AGENT_TRACKER_MAPPING_PROVIDER"] = "openai" env["AGENT_TRACKER_MAPPING_PROVIDER"] = "openai"
env["AGENT_TRACKER_MAPPING_MODEL_NAME"] = "gpt-4o-mini" env["AGENT_TRACKER_MAPPING_MODEL_NAME"] = "gpt-4o-mini"
env["AGENT_TRACKER_MAPPING_TEMPERATURE"] = "0.1" env["AGENT_TRACKER_MAPPING_TEMPERATURE"] = "0.1"
env["AGENT_TRACKER_MAPPING_MAX_TOKENS"] = "1024" env["AGENT_TRACKER_MAPPING_MAX_TOKENS"] = "1024"
// Agents — tracker-enrichment override
env["AGENT_TRACKER_ENRICHMENT_PROVIDER"] = "openai"
env["AGENT_TRACKER_ENRICHMENT_MODEL_NAME"] = "gpt-4o"
env["AGENT_TRACKER_ENRICHMENT_TEMPERATURE"] = "0.2"
env["AGENT_TRACKER_ENRICHMENT_MAX_TOKENS"] = "2048"
// Tracker worker tuning override // Tracker worker tuning override
env["TRACKER_MAPPING_INTERVAL"] = "20" env["TRACKER_MAPPING_INTERVAL"] = "20"
env["TRACKER_MAPPING_MAX_CONCURRENCY"] = "5" env["TRACKER_MAPPING_MAX_CONCURRENCY"] = "5"
env["TRACKER_MAPPING_STALE_AFTER"] = "1200" env["TRACKER_MAPPING_STALE_AFTER"] = "1200"
env["TRACKER_MAPPING_AGENT_TIMEOUT"] = "30" env["TRACKER_MAPPING_AGENT_TIMEOUT"] = "30"
env["TRACKER_MAPPING_AGENT_MAX_TURNS"] = "6" env["TRACKER_MAPPING_AGENT_MAX_TURNS"] = "6"
env["TRACKER_MAPPING_DISAMBIGUATION_AGENT_TIMEOUT"] = "35"
env["COMMON_PATTERN_ENRICHMENT_INTERVAL"] = "15" env["COMMON_PATTERN_ENRICHMENT_INTERVAL"] = "15"
env["COMMON_PATTERN_ENRICHMENT_MAX_CONCURRENCY"] = "4" env["COMMON_PATTERN_ENRICHMENT_MAX_CONCURRENCY"] = "4"
env["COMMON_PATTERN_ENRICHMENT_STALE_AFTER"] = "900" env["COMMON_PATTERN_ENRICHMENT_STALE_AFTER"] = "900"
@@ -443,17 +463,28 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
assert.Equal(t, "gpt-4o", cfg.Probod.Agents.ThirdPartyVetter.ModelName) assert.Equal(t, "gpt-4o", cfg.Probod.Agents.ThirdPartyVetter.ModelName)
assert.Equal(t, new(0.3), cfg.Probod.Agents.ThirdPartyVetter.Temperature) assert.Equal(t, new(0.3), cfg.Probod.Agents.ThirdPartyVetter.Temperature)
assert.Equal(t, new(8192), cfg.Probod.Agents.ThirdPartyVetter.MaxTokens) assert.Equal(t, new(8192), cfg.Probod.Agents.ThirdPartyVetter.MaxTokens)
// Agents — third-party-disambiguation overrides
assert.Equal(t, "anthropic", cfg.Probod.Agents.ThirdPartyDisambiguation.Provider)
assert.Equal(t, "claude-sonnet-4-20250514", cfg.Probod.Agents.ThirdPartyDisambiguation.ModelName)
assert.Equal(t, new(0.4), cfg.Probod.Agents.ThirdPartyDisambiguation.Temperature)
assert.Equal(t, new(2048), cfg.Probod.Agents.ThirdPartyDisambiguation.MaxTokens)
// Agents — tracker-mapping overrides // Agents — tracker-mapping overrides
assert.Equal(t, "openai", cfg.Probod.Agents.TrackerMapping.Provider) assert.Equal(t, "openai", cfg.Probod.Agents.TrackerMapping.Provider)
assert.Equal(t, "gpt-4o-mini", cfg.Probod.Agents.TrackerMapping.ModelName) assert.Equal(t, "gpt-4o-mini", cfg.Probod.Agents.TrackerMapping.ModelName)
assert.Equal(t, new(0.1), cfg.Probod.Agents.TrackerMapping.Temperature) assert.Equal(t, new(0.1), cfg.Probod.Agents.TrackerMapping.Temperature)
assert.Equal(t, new(1024), cfg.Probod.Agents.TrackerMapping.MaxTokens) assert.Equal(t, new(1024), cfg.Probod.Agents.TrackerMapping.MaxTokens)
// Agents — tracker-enrichment overrides
assert.Equal(t, "openai", cfg.Probod.Agents.TrackerEnrichment.Provider)
assert.Equal(t, "gpt-4o", cfg.Probod.Agents.TrackerEnrichment.ModelName)
assert.Equal(t, new(0.2), cfg.Probod.Agents.TrackerEnrichment.Temperature)
assert.Equal(t, new(2048), cfg.Probod.Agents.TrackerEnrichment.MaxTokens)
// Tracker worker tuning — overrides // Tracker worker tuning — overrides
assert.Equal(t, 20, cfg.Probod.TrackerMappingWorker.Interval) assert.Equal(t, 20, cfg.Probod.TrackerMappingWorker.Interval)
assert.Equal(t, 5, cfg.Probod.TrackerMappingWorker.MaxConcurrency) assert.Equal(t, 5, cfg.Probod.TrackerMappingWorker.MaxConcurrency)
assert.Equal(t, 1200, cfg.Probod.TrackerMappingWorker.StaleAfter) assert.Equal(t, 1200, cfg.Probod.TrackerMappingWorker.StaleAfter)
assert.Equal(t, 30, cfg.Probod.TrackerMappingWorker.AgentTimeout) assert.Equal(t, 30, cfg.Probod.TrackerMappingWorker.AgentTimeout)
assert.Equal(t, 6, cfg.Probod.TrackerMappingWorker.AgentMaxTurns) assert.Equal(t, 6, cfg.Probod.TrackerMappingWorker.AgentMaxTurns)
assert.Equal(t, 35, cfg.Probod.TrackerMappingWorker.DisambiguationAgentTimeout)
assert.Equal(t, 15, cfg.Probod.CommonPatternEnrichmentWorker.Interval) assert.Equal(t, 15, cfg.Probod.CommonPatternEnrichmentWorker.Interval)
assert.Equal(t, 4, cfg.Probod.CommonPatternEnrichmentWorker.MaxConcurrency) assert.Equal(t, 4, cfg.Probod.CommonPatternEnrichmentWorker.MaxConcurrency)
assert.Equal(t, 900, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter) assert.Equal(t, 900, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter)

View File

@@ -39,35 +39,49 @@ import (
// known set of ids (e.g. proboctl). It is enrichment's single source of // known set of ids (e.g. proboctl). It is enrichment's single source of
// truth - the worker is a thin queue poller that delegates here. // truth - the worker is a thin queue poller that delegates here.
type CommonPatternEnricher struct { type CommonPatternEnricher struct {
pg *pg.Client pg *pg.Client
logger *log.Logger logger *log.Logger
enrichmentAgent *agent.Agent enrichmentAgent *agent.Agent
mappingAgent *agent.Agent mappingAgent *agent.Agent
agentTimeout time.Duration enrichmentTimeout time.Duration
mappingTimeout time.Duration
} }
// NewCommonPatternEnricher builds the enricher from the shared tracker // NewCommonPatternEnricher builds the enricher from the enrichment and
// agents config. When no LLM client is configured the agents are left nil // mapping agent configs. It runs the enrichment agent to research a
// and Enabled reports false; callers must gate on Enabled before running. // description and reuses the mapping agent to attribute a vendor first,
// so it needs both configs. When the enrichment config has no LLM client
// the agents are left nil and Enabled reports false; callers must gate on
// Enabled before running.
func NewCommonPatternEnricher( func NewCommonPatternEnricher(
pgClient *pg.Client, pgClient *pg.Client,
logger *log.Logger, logger *log.Logger,
cfg TrackerAgentsConfig, enrichmentCfg TrackerEnrichmentAgentConfig,
mappingCfg TrackerMappingAgentConfig,
) *CommonPatternEnricher { ) *CommonPatternEnricher {
agentTimeout := cfg.AgentTimeout enrichmentTimeout := enrichmentCfg.Timeout
if agentTimeout <= 0 { if enrichmentTimeout <= 0 {
agentTimeout = defaultAgentTimeout enrichmentTimeout = defaultAgentTimeout
}
mappingTimeout := mappingCfg.Timeout
if mappingTimeout <= 0 {
mappingTimeout = defaultAgentTimeout
} }
e := &CommonPatternEnricher{ e := &CommonPatternEnricher{
pg: pgClient, pg: pgClient,
logger: logger, logger: logger,
agentTimeout: agentTimeout, enrichmentTimeout: enrichmentTimeout,
mappingTimeout: mappingTimeout,
} }
if cfg.LLMClient != nil { if enrichmentCfg.LLMClient != nil {
e.enrichmentAgent = buildCommonPatternEnrichmentAgent(cfg, pgClient, logger) e.enrichmentAgent = buildCommonPatternEnrichmentAgent(enrichmentCfg, pgClient, logger)
e.mappingAgent = buildTrackerMappingAgent(cfg, pgClient, logger) }
if mappingCfg.LLMClient != nil {
e.mappingAgent = buildTrackerMappingAgent(mappingCfg, pgClient, logger)
} }
return e return e
@@ -251,7 +265,7 @@ func (e *CommonPatternEnricher) research(
) (string, error) { ) (string, error) {
prompt := buildEnrichmentPrompt(cp, thirdPartyName) prompt := buildEnrichmentPrompt(cp, thirdPartyName)
agentCtx, cancel := context.WithTimeout(ctx, e.agentTimeout) agentCtx, cancel := context.WithTimeout(ctx, e.enrichmentTimeout)
defer cancel() defer cancel()
result, err := agent.RunTyped[CommonPatternEnrichmentResult]( result, err := agent.RunTyped[CommonPatternEnrichmentResult](
@@ -287,7 +301,7 @@ func (e *CommonPatternEnricher) identifyThirdParty(
prompt := buildCommonPatternIdentificationPrompt(cp) prompt := buildCommonPatternIdentificationPrompt(cp)
agentCtx, cancel := context.WithTimeout(ctx, e.agentTimeout) agentCtx, cancel := context.WithTimeout(ctx, e.mappingTimeout)
defer cancel() defer cancel()
result, err := agent.RunTyped[TrackerMappingAgentResult]( result, err := agent.RunTyped[TrackerMappingAgentResult](

View File

@@ -36,7 +36,7 @@ type CommonPatternEnrichmentResult struct {
} }
func buildCommonPatternEnrichmentAgent( func buildCommonPatternEnrichmentAgent(
cfg TrackerAgentsConfig, cfg TrackerEnrichmentAgentConfig,
pgClient *pg.Client, pgClient *pg.Client,
logger *log.Logger, logger *log.Logger,
) *agent.Agent { ) *agent.Agent {
@@ -53,7 +53,7 @@ func buildCommonPatternEnrichmentAgent(
panic(fmt.Sprintf("cookiebanner: cannot build tracker enrichment output type: %s", err)) panic(fmt.Sprintf("cookiebanner: cannot build tracker enrichment output type: %s", err))
} }
maxTurns := cfg.EnrichmentMaxTurns maxTurns := cfg.MaxTurns
if maxTurns < 1 { if maxTurns < 1 {
maxTurns = defaultEnrichmentMaxTurns maxTurns = defaultEnrichmentMaxTurns
} }

View File

@@ -49,7 +49,8 @@ type commonPatternEnrichmentHandler struct {
func NewCommonPatternEnrichmentWorker( func NewCommonPatternEnrichmentWorker(
pgClient *pg.Client, pgClient *pg.Client,
logger *log.Logger, logger *log.Logger,
cfg TrackerAgentsConfig, enrichmentCfg TrackerEnrichmentAgentConfig,
mappingCfg TrackerMappingAgentConfig,
staleAfter time.Duration, staleAfter time.Duration,
opts ...worker.Option, opts ...worker.Option,
) *worker.Worker[coredata.CommonTrackerPattern] { ) *worker.Worker[coredata.CommonTrackerPattern] {
@@ -60,7 +61,7 @@ func NewCommonPatternEnrichmentWorker(
h := &commonPatternEnrichmentHandler{ h := &commonPatternEnrichmentHandler{
pg: pgClient, pg: pgClient,
logger: logger, logger: logger,
enricher: NewCommonPatternEnricher(pgClient, logger, cfg), enricher: NewCommonPatternEnricher(pgClient, logger, enrichmentCfg, mappingCfg),
staleAfter: staleAfter, staleAfter: staleAfter,
} }

View File

@@ -20,23 +20,38 @@ import (
"go.probo.inc/probo/pkg/llm" "go.probo.inc/probo/pkg/llm"
) )
// TrackerAgentsConfig configures the tracker agents that share one LLM // TrackerMappingAgentConfig configures the tracker-mapping agent
// client, model, and tool surface: the tracker-mapping agent (catalog // (catalog identification). It uses DB-backed search tools and may also
// identification) and the common-pattern enrichment agent (description // use Firecrawl for web search when an API key is supplied.
// research). Both use DB-backed search tools and may also use Firecrawl
// for web search when an API key is supplied.
// //
// MaxTokens and Temperature bound and steer each LLM call (both // MaxTokens and Temperature bound and steer the LLM call (the output is
// outputs are tiny structured JSON). AgentTimeout caps a single agent // tiny structured JSON). Timeout caps a single agent run and MaxTurns
// run, and the per-worker max-turns bound the agent reasoning loop. // bounds the agent reasoning loop. Zero-valued tuning fields fall back
// Zero-valued tuning fields fall back to package defaults. // to package defaults.
type TrackerAgentsConfig struct { type TrackerMappingAgentConfig struct {
LLMClient *llm.Client LLMClient *llm.Client
Model string Model string
FirecrawlAPIKey string FirecrawlAPIKey string
MaxTokens *int MaxTokens *int
Temperature *float64 Temperature *float64
AgentTimeout time.Duration Timeout time.Duration
MappingMaxTurns int MaxTurns int
EnrichmentMaxTurns int }
// TrackerEnrichmentAgentConfig configures the common-pattern enrichment
// agent (description research). It uses DB-backed search tools and may
// also use Firecrawl for web search when an API key is supplied.
//
// MaxTokens and Temperature bound and steer the LLM call (the output is
// tiny structured JSON). Timeout caps a single agent run and MaxTurns
// bounds the agent reasoning loop. Zero-valued tuning fields fall back
// to package defaults.
type TrackerEnrichmentAgentConfig struct {
LLMClient *llm.Client
Model string
FirecrawlAPIKey string
MaxTokens *int
Temperature *float64
Timeout time.Duration
MaxTurns int
} }

View File

@@ -76,7 +76,7 @@ type TrackerMappingAgentResult struct {
} }
func buildTrackerMappingAgent( func buildTrackerMappingAgent(
cfg TrackerAgentsConfig, cfg TrackerMappingAgentConfig,
pgClient *pg.Client, pgClient *pg.Client,
logger *log.Logger, logger *log.Logger,
) *agent.Agent { ) *agent.Agent {
@@ -94,7 +94,7 @@ func buildTrackerMappingAgent(
panic(fmt.Sprintf("cookiebanner: cannot build tracker identification output type: %s", err)) panic(fmt.Sprintf("cookiebanner: cannot build tracker identification output type: %s", err))
} }
maxTurns := cfg.MappingMaxTurns maxTurns := cfg.MaxTurns
if maxTurns < 1 { if maxTurns < 1 {
maxTurns = defaultMappingMaxTurns maxTurns = defaultMappingMaxTurns
} }

View File

@@ -50,12 +50,12 @@ type trackerMappingHandler struct {
func NewTrackerMappingWorker( func NewTrackerMappingWorker(
pgClient *pg.Client, pgClient *pg.Client,
logger *log.Logger, logger *log.Logger,
mappingCfg TrackerAgentsConfig, mappingCfg TrackerMappingAgentConfig,
disambiguationCfg thirdparty.DisambiguationConfig, disambiguationCfg thirdparty.DisambiguationAgentConfig,
staleAfter time.Duration, staleAfter time.Duration,
opts ...worker.Option, opts ...worker.Option,
) *worker.Worker[coredata.TrackerPattern] { ) *worker.Worker[coredata.TrackerPattern] {
agentTimeout := mappingCfg.AgentTimeout agentTimeout := mappingCfg.Timeout
if agentTimeout <= 0 { if agentTimeout <= 0 {
agentTimeout = defaultAgentTimeout agentTimeout = defaultAgentTimeout
} }

View File

@@ -17,12 +17,10 @@ package cmdutil
import ( import (
"fmt" "fmt"
"os" "os"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
"go.opentelemetry.io/otel/trace/noop"
"go.probo.inc/probo/pkg/agentsbuild"
"go.probo.inc/probo/pkg/cmd/iostreams" "go.probo.inc/probo/pkg/cmd/iostreams"
"go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/proboctl/pgconn" "go.probo.inc/probo/pkg/proboctl/pgconn"
@@ -66,14 +64,23 @@ func (f *Factory) ProbodConfig() (probodconfig.Config, error) {
return full.Probod, nil return full.Probod, nil
} }
// TrackerAgentsConfig builds the tracker-agents config (LLM client + // TrackerAgentsConfig builds the enrichment and mapping agent configs
// Firecrawl key) from the shared probod config for in-process agent // (LLM clients + Firecrawl key) from the shared probod config for
// execution, e.g. synchronous common-pattern re-enrichment. It errors // in-process agent execution, e.g. synchronous common-pattern
// when no LLM provider is configured. // re-enrichment. The enricher runs the enrichment agent and reuses the
func (f *Factory) TrackerAgentsConfig() (cookiebanner.TrackerAgentsConfig, error) { // mapping agent to attribute a vendor first, so both configs are
// returned. It errors when no LLM provider is configured.
//
// This duplicates the small wiring probod does in buildTrackerAgents
// rather than sharing a package, keeping the two executables decoupled.
func (f *Factory) TrackerAgentsConfig() (cookiebanner.TrackerEnrichmentAgentConfig, cookiebanner.TrackerMappingAgentConfig, error) {
cfg, err := f.ProbodConfig() cfg, err := f.ProbodConfig()
if err != nil { if err != nil {
return cookiebanner.TrackerAgentsConfig{}, err return cookiebanner.TrackerEnrichmentAgentConfig{}, cookiebanner.TrackerMappingAgentConfig{}, err
}
if cfg.Agents.TrackerMapping.Provider == "" {
return cookiebanner.TrackerEnrichmentAgentConfig{}, cookiebanner.TrackerMappingAgentConfig{}, fmt.Errorf("no LLM provider configured; set llm.tracker-mapping.provider in %q", f.CfgFile)
} }
logger := log.NewLogger( logger := log.NewLogger(
@@ -81,19 +88,42 @@ func (f *Factory) TrackerAgentsConfig() (cookiebanner.TrackerAgentsConfig, error
log.WithOutput(f.IOStreams.ErrOut), log.WithOutput(f.IOStreams.ErrOut),
) )
trackerCfg, _, err := agentsbuild.BuildTrackerAgentsConfig( firecrawlAPIKey := cfg.Agents.Tools.FirecrawlAPIKey
cfg,
logger, mappingAgentCfg, mappingClient, err := resolveAgentClient(cfg.Agents, "tracker-mapping", cfg.Agents.TrackerMapping, logger)
noop.NewTracerProvider(),
prometheus.NewRegistry(),
)
if err != nil { if err != nil {
return cookiebanner.TrackerAgentsConfig{}, fmt.Errorf("cannot build tracker agents config: %w", err) return cookiebanner.TrackerEnrichmentAgentConfig{}, cookiebanner.TrackerMappingAgentConfig{}, fmt.Errorf("cannot build tracker mapping agent: %w", err)
} }
if trackerCfg.LLMClient == nil { mappingCfg := cookiebanner.TrackerMappingAgentConfig{
return cookiebanner.TrackerAgentsConfig{}, fmt.Errorf("no LLM provider configured; set llm.tracker-mapping.provider in %q", f.CfgFile) LLMClient: mappingClient,
Model: mappingAgentCfg.ModelName,
FirecrawlAPIKey: firecrawlAPIKey,
MaxTokens: mappingAgentCfg.MaxTokens,
Temperature: mappingAgentCfg.Temperature,
Timeout: time.Duration(cfg.TrackerMappingWorker.AgentTimeout) * time.Second,
MaxTurns: cfg.TrackerMappingWorker.AgentMaxTurns,
} }
return trackerCfg, nil enrichmentSlot := cfg.Agents.TrackerEnrichment
if enrichmentSlot.Provider == "" {
enrichmentSlot = cfg.Agents.TrackerMapping
}
enrichmentAgentCfg, enrichmentClient, err := resolveAgentClient(cfg.Agents, "tracker-enrichment", enrichmentSlot, logger)
if err != nil {
return cookiebanner.TrackerEnrichmentAgentConfig{}, cookiebanner.TrackerMappingAgentConfig{}, fmt.Errorf("cannot build tracker enrichment agent: %w", err)
}
enrichmentCfg := cookiebanner.TrackerEnrichmentAgentConfig{
LLMClient: enrichmentClient,
Model: enrichmentAgentCfg.ModelName,
FirecrawlAPIKey: firecrawlAPIKey,
MaxTokens: enrichmentAgentCfg.MaxTokens,
Temperature: enrichmentAgentCfg.Temperature,
Timeout: time.Duration(cfg.CommonPatternEnrichmentWorker.AgentTimeout) * time.Second,
MaxTurns: cfg.CommonPatternEnrichmentWorker.AgentMaxTurns,
}
return enrichmentCfg, mappingCfg, nil
} }

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package cmdutil
import (
"fmt"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
"go.opentelemetry.io/otel/trace/noop"
"go.probo.inc/probo/pkg/llm"
llmanthropic "go.probo.inc/probo/pkg/llm/anthropic"
llmopenai "go.probo.inc/probo/pkg/llm/openai"
"go.probo.inc/probo/pkg/probodconfig"
)
// resolveAgentClient resolves an agent's effective config from defaults
// and builds an LLM client for it. The name is used in the logger name
// and error messages. proboctl runs synchronously with no tracing or
// metrics, so it duplicates probod's wiring with a no-op tracer and a
// throwaway registry rather than sharing a package.
func resolveAgentClient(
agents probodconfig.AgentsConfig,
name string,
agent probodconfig.LLMAgentConfig,
l *log.Logger,
) (probodconfig.LLMAgentConfig, *llm.Client, error) {
resolved := agents.ResolveAgent(agent)
providerCfg, ok := agents.Providers[resolved.Provider]
if !ok {
return probodconfig.LLMAgentConfig{}, nil, fmt.Errorf("unknown LLM provider %q for %s agent", resolved.Provider, name)
}
client, err := buildLLMClient(providerCfg, l.Named("llm."+name))
if err != nil {
return probodconfig.LLMAgentConfig{}, nil, fmt.Errorf("cannot create %s LLM client: %w", name, err)
}
return resolved, client, nil
}
// buildLLMClient creates an LLM client for the given provider config.
func buildLLMClient(cfg probodconfig.LLMProviderConfig, l *log.Logger) (*llm.Client, error) {
providerType := cfg.Type
if providerType == "" {
providerType = "openai"
}
httpClient := httpclient.DefaultPooledClient(
httpclient.WithLogger(l),
httpclient.WithTracerProvider(noop.NewTracerProvider()),
httpclient.WithRegisterer(prometheus.NewRegistry()),
)
switch providerType {
case "openai":
p := llmopenai.NewProvider(
cfg.APIKey,
llmopenai.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"openai",
llm.WithLogger(l),
), nil
case "anthropic":
p := llmanthropic.NewProvider(
cfg.APIKey,
llmanthropic.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"anthropic",
llm.WithLogger(l),
), nil
case "bedrock":
return nil, fmt.Errorf("bedrock provider not yet wired; requires aws.Config")
default:
return nil, fmt.Errorf("unsupported LLM provider type: %q", providerType)
}
}

View File

@@ -132,7 +132,7 @@ func newCmdReenrich(f *cmdutil.Factory) *cobra.Command {
return nil return nil
} }
cfg, err := f.TrackerAgentsConfig() enrichmentCfg, mappingCfg, err := f.TrackerAgentsConfig()
if err != nil { if err != nil {
return err return err
} }
@@ -142,7 +142,7 @@ func newCmdReenrich(f *cmdutil.Factory) *cobra.Command {
log.WithOutput(f.IOStreams.ErrOut), log.WithOutput(f.IOStreams.ErrOut),
) )
enricher := cookiebanner.NewCommonPatternEnricher(pgClient, logger, cfg) enricher := cookiebanner.NewCommonPatternEnricher(pgClient, logger, enrichmentCfg, mappingCfg)
enriched, err := enricher.EnrichByIDs(ctx, ids, flagConcurrency) enriched, err := enricher.EnrichByIDs(ctx, ids, flagConcurrency)

View File

@@ -15,17 +15,70 @@
package probod package probod
import ( import (
"fmt"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/agentsbuild"
"go.probo.inc/probo/pkg/llm" "go.probo.inc/probo/pkg/llm"
llmanthropic "go.probo.inc/probo/pkg/llm/anthropic"
llmopenai "go.probo.inc/probo/pkg/llm/openai"
) )
// buildLLMClient creates an LLM client for the given provider config.
func (impl *Implm) buildLLMClient(
cfg LLMProviderConfig,
l *log.Logger,
tp trace.TracerProvider,
r prometheus.Registerer,
) (*llm.Client, error) {
providerType := cfg.Type
if providerType == "" {
providerType = "openai"
}
httpClient := httpclient.DefaultPooledClient(
httpclient.WithLogger(l),
httpclient.WithTracerProvider(tp),
httpclient.WithRegisterer(r),
)
switch providerType {
case "openai":
p := llmopenai.NewProvider(
cfg.APIKey,
llmopenai.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"openai",
llm.WithLogger(l),
llm.WithTracerProvider(tp),
), nil
case "anthropic":
p := llmanthropic.NewProvider(
cfg.APIKey,
llmanthropic.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"anthropic",
llm.WithLogger(l),
llm.WithTracerProvider(tp),
), nil
case "bedrock":
return nil, fmt.Errorf("bedrock provider not yet wired; requires aws.Config")
default:
return nil, fmt.Errorf("unsupported LLM provider type: %q", providerType)
}
}
// resolveAgentClient resolves the agent's effective config from defaults and // resolveAgentClient resolves the agent's effective config from defaults and
// builds an LLM client for it. The name parameter is used in the logger and // builds an LLM client for it. The name parameter is used in the logger and
// in error messages. It delegates to pkg/agentsbuild so probod and proboctl // in error messages.
// wire LLM clients identically.
func (impl *Implm) resolveAgentClient( func (impl *Implm) resolveAgentClient(
name string, name string,
agent LLMAgentConfig, agent LLMAgentConfig,
@@ -33,5 +86,17 @@ func (impl *Implm) resolveAgentClient(
tp trace.TracerProvider, tp trace.TracerProvider,
r prometheus.Registerer, r prometheus.Registerer,
) (LLMAgentConfig, *llm.Client, error) { ) (LLMAgentConfig, *llm.Client, error) {
return agentsbuild.ResolveAgentClient(impl.cfg.Agents, name, agent, l, tp, r) resolved := impl.cfg.Agents.ResolveAgent(agent)
providerCfg, ok := impl.cfg.Agents.Providers[resolved.Provider]
if !ok {
return LLMAgentConfig{}, nil, fmt.Errorf("unknown LLM provider %q for %s agent", resolved.Provider, name)
}
client, err := impl.buildLLMClient(providerCfg, l.Named("llm."+name), tp, r)
if err != nil {
return LLMAgentConfig{}, nil, fmt.Errorf("cannot create %s LLM client: %w", name, err)
}
return resolved, client, nil
} }

View File

@@ -324,7 +324,7 @@ func (impl *Implm) Run(
return err return err
} }
trackerAgentsCfg, thirdPartyDisambiguationCfg, err := impl.buildTrackerAgentsConfig(l, tp, r) trackerMappingCfg, trackerEnrichmentCfg, thirdPartyDisambiguationCfg, err := impl.buildTrackerAgents(l, tp, r)
if err != nil { if err != nil {
return err return err
} }
@@ -790,7 +790,7 @@ func (impl *Implm) Run(
trackerMappingWorker := cookiebanner.NewTrackerMappingWorker( trackerMappingWorker := cookiebanner.NewTrackerMappingWorker(
pgClient, pgClient,
l, l,
trackerAgentsCfg, trackerMappingCfg,
thirdPartyDisambiguationCfg, thirdPartyDisambiguationCfg,
time.Duration(impl.cfg.TrackerMappingWorker.StaleAfter)*time.Second, time.Duration(impl.cfg.TrackerMappingWorker.StaleAfter)*time.Second,
worker.WithInterval(time.Duration(impl.cfg.TrackerMappingWorker.Interval)*time.Second), worker.WithInterval(time.Duration(impl.cfg.TrackerMappingWorker.Interval)*time.Second),
@@ -811,14 +811,12 @@ func (impl *Implm) Run(
// the tracker agents are configured. // the tracker agents are configured.
stopCommonPatternEnrichmentWorker := func() {} stopCommonPatternEnrichmentWorker := func() {}
if trackerAgentsCfg.LLMClient != nil { if trackerEnrichmentCfg.LLMClient != nil {
enrichmentCfg := trackerAgentsCfg
enrichmentCfg.AgentTimeout = time.Duration(impl.cfg.CommonPatternEnrichmentWorker.AgentTimeout) * time.Second
commonPatternEnrichmentWorker := cookiebanner.NewCommonPatternEnrichmentWorker( commonPatternEnrichmentWorker := cookiebanner.NewCommonPatternEnrichmentWorker(
pgClient, pgClient,
l, l,
enrichmentCfg, trackerEnrichmentCfg,
trackerMappingCfg,
time.Duration(impl.cfg.CommonPatternEnrichmentWorker.StaleAfter)*time.Second, time.Duration(impl.cfg.CommonPatternEnrichmentWorker.StaleAfter)*time.Second,
worker.WithInterval(time.Duration(impl.cfg.CommonPatternEnrichmentWorker.Interval)*time.Second), worker.WithInterval(time.Duration(impl.cfg.CommonPatternEnrichmentWorker.Interval)*time.Second),
worker.WithMaxConcurrency(impl.cfg.CommonPatternEnrichmentWorker.MaxConcurrency), worker.WithMaxConcurrency(impl.cfg.CommonPatternEnrichmentWorker.MaxConcurrency),

View File

@@ -15,22 +15,108 @@
package probod package probod
import ( import (
"fmt"
"time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/agentsbuild"
"go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/thirdparty" "go.probo.inc/probo/pkg/thirdparty"
) )
// buildTrackerAgentsConfig wires the tracker agents (mapping, enrichment, // buildTrackerAgents wires the three tracker agents from the probod
// disambiguation) from the probod config. It delegates to pkg/agentsbuild // config, each with its own LLM client and tuning: the tracker-mapping
// so probod and proboctl build the same agent configuration; see that // agent (catalog identification), the common-pattern enrichment agent
// package for the wiring rationale. // (description research), and the third-party disambiguation agent. All
func (impl *Implm) buildTrackerAgentsConfig( // are opt-in: when `llm.tracker-mapping.provider` is empty it returns
// zero configs (nil LLM clients) so callers run without agent fallback.
//
// The enrichment and disambiguation agents fall back to the
// tracker-mapping config when their own provider slot is empty, so a
// deployment that configures only `tracker-mapping` keeps wiring all
// three agents.
func (impl *Implm) buildTrackerAgents(
l *log.Logger, l *log.Logger,
tp trace.TracerProvider, tp trace.TracerProvider,
r prometheus.Registerer, r prometheus.Registerer,
) (cookiebanner.TrackerAgentsConfig, thirdparty.DisambiguationConfig, error) { ) (cookiebanner.TrackerMappingAgentConfig, cookiebanner.TrackerEnrichmentAgentConfig, thirdparty.DisambiguationAgentConfig, error) {
return agentsbuild.BuildTrackerAgentsConfig(impl.cfg, l, tp, r) if impl.cfg.Agents.TrackerMapping.Provider == "" {
return cookiebanner.TrackerMappingAgentConfig{}, cookiebanner.TrackerEnrichmentAgentConfig{}, thirdparty.DisambiguationAgentConfig{}, nil
}
firecrawlAPIKey := impl.cfg.Agents.Tools.FirecrawlAPIKey
mappingAgentCfg, mappingClient, err := impl.resolveAgentClient(
"tracker-mapping",
impl.cfg.Agents.TrackerMapping,
l,
tp,
r,
)
if err != nil {
return cookiebanner.TrackerMappingAgentConfig{}, cookiebanner.TrackerEnrichmentAgentConfig{}, thirdparty.DisambiguationAgentConfig{}, fmt.Errorf("cannot resolve tracker mapping agent client: %w", err)
}
mappingCfg := cookiebanner.TrackerMappingAgentConfig{
LLMClient: mappingClient,
Model: mappingAgentCfg.ModelName,
FirecrawlAPIKey: firecrawlAPIKey,
MaxTokens: mappingAgentCfg.MaxTokens,
Temperature: mappingAgentCfg.Temperature,
Timeout: time.Duration(impl.cfg.TrackerMappingWorker.AgentTimeout) * time.Second,
MaxTurns: impl.cfg.TrackerMappingWorker.AgentMaxTurns,
}
enrichmentSlot := impl.cfg.Agents.TrackerEnrichment
if enrichmentSlot.Provider == "" {
enrichmentSlot = impl.cfg.Agents.TrackerMapping
}
enrichmentAgentCfg, enrichmentClient, err := impl.resolveAgentClient(
"tracker-enrichment",
enrichmentSlot,
l,
tp,
r,
)
if err != nil {
return cookiebanner.TrackerMappingAgentConfig{}, cookiebanner.TrackerEnrichmentAgentConfig{}, thirdparty.DisambiguationAgentConfig{}, fmt.Errorf("cannot resolve tracker enrichment agent client: %w", err)
}
enrichmentCfg := cookiebanner.TrackerEnrichmentAgentConfig{
LLMClient: enrichmentClient,
Model: enrichmentAgentCfg.ModelName,
FirecrawlAPIKey: firecrawlAPIKey,
MaxTokens: enrichmentAgentCfg.MaxTokens,
Temperature: enrichmentAgentCfg.Temperature,
Timeout: time.Duration(impl.cfg.CommonPatternEnrichmentWorker.AgentTimeout) * time.Second,
MaxTurns: impl.cfg.CommonPatternEnrichmentWorker.AgentMaxTurns,
}
disambiguationSlot := impl.cfg.Agents.ThirdPartyDisambiguation
if disambiguationSlot.Provider == "" {
disambiguationSlot = impl.cfg.Agents.TrackerMapping
}
disambiguationAgentCfg, disambiguationClient, err := impl.resolveAgentClient(
"third-party-disambiguation",
disambiguationSlot,
l,
tp,
r,
)
if err != nil {
return cookiebanner.TrackerMappingAgentConfig{}, cookiebanner.TrackerEnrichmentAgentConfig{}, thirdparty.DisambiguationAgentConfig{}, fmt.Errorf("cannot resolve third party disambiguation agent client: %w", err)
}
disambiguationCfg := thirdparty.DisambiguationAgentConfig{
LLMClient: disambiguationClient,
Model: disambiguationAgentCfg.ModelName,
MaxTokens: disambiguationAgentCfg.MaxTokens,
Temperature: disambiguationAgentCfg.Temperature,
Timeout: time.Duration(impl.cfg.TrackerMappingWorker.DisambiguationAgentTimeout) * time.Second,
}
return mappingCfg, enrichmentCfg, disambiguationCfg, nil
} }

View File

@@ -50,22 +50,24 @@ type (
} }
// TrackerMappingWorkerConfig holds worker-side tuning for the // TrackerMappingWorkerConfig holds worker-side tuning for the
// tracker-mapping background worker. LLM parameters for the agents // tracker-mapping background worker. LLM parameters for the mapping
// it runs live under AgentsConfig.TrackerMapping. AgentTimeout and // agent it runs live under AgentsConfig.TrackerMapping. AgentTimeout
// AgentMaxTurns bound a single agent run (the identification and // and AgentMaxTurns bound a single mapping agent run.
// disambiguation agents). // DisambiguationAgentTimeout caps a single third-party
// disambiguation agent run; that agent runs inside this worker but
// uses its own LLM parameters from AgentsConfig.ThirdPartyDisambiguation.
TrackerMappingWorkerConfig struct { TrackerMappingWorkerConfig struct {
Interval int `json:"interval"` // seconds between polls Interval int `json:"interval"` // seconds between polls
MaxConcurrency int `json:"max-concurrency"` MaxConcurrency int `json:"max-concurrency"`
StaleAfter int `json:"stale-after"` // seconds before a claim is recycled StaleAfter int `json:"stale-after"` // seconds before a claim is recycled
AgentTimeout int `json:"agent-timeout"` // seconds, single agent run AgentTimeout int `json:"agent-timeout"` // seconds, single agent run
AgentMaxTurns int `json:"agent-max-turns"` AgentMaxTurns int `json:"agent-max-turns"`
DisambiguationAgentTimeout int `json:"disambiguation-agent-timeout"` // seconds, single disambiguation run
} }
// CommonPatternEnrichmentWorkerConfig holds worker-side tuning for // CommonPatternEnrichmentWorkerConfig holds worker-side tuning for
// the common-pattern enrichment background worker. LLM parameters // the common-pattern enrichment background worker. LLM parameters
// for the enrichment agent live under AgentsConfig.TrackerMapping // for the enrichment agent live under AgentsConfig.TrackerEnrichment.
// (the agents share one config slot).
CommonPatternEnrichmentWorkerConfig struct { CommonPatternEnrichmentWorkerConfig struct {
Interval int `json:"interval"` // seconds between polls Interval int `json:"interval"` // seconds between polls
MaxConcurrency int `json:"max-concurrency"` MaxConcurrency int `json:"max-concurrency"`
@@ -84,13 +86,15 @@ type (
// settings. Default is used as a fallback when an agent-specific field // settings. Default is used as a fallback when an agent-specific field
// is zero-valued. // is zero-valued.
AgentsConfig struct { AgentsConfig struct {
Providers map[string]LLMProviderConfig `json:"providers"` Providers map[string]LLMProviderConfig `json:"providers"`
Default LLMAgentConfig `json:"defaults"` Default LLMAgentConfig `json:"defaults"`
Probo LLMAgentConfig `json:"probo"` Probo LLMAgentConfig `json:"probo"`
EvidenceDescriber LLMAgentConfig `json:"evidence-describer"` EvidenceDescriber LLMAgentConfig `json:"evidence-describer"`
ThirdPartyVetter LLMAgentConfig `json:"third-party-vetter"` ThirdPartyVetter LLMAgentConfig `json:"third-party-vetter"`
TrackerMapping LLMAgentConfig `json:"tracker-mapping"` ThirdPartyDisambiguation LLMAgentConfig `json:"third-party-disambiguation"`
Tools AgentToolsConfig `json:"tools"` TrackerMapping LLMAgentConfig `json:"tracker-mapping"`
TrackerEnrichment LLMAgentConfig `json:"tracker-enrichment"`
Tools AgentToolsConfig `json:"tools"`
} }
) )

View File

@@ -54,7 +54,7 @@ const (
defaultDisambiguationMaxTokens = 4096 defaultDisambiguationMaxTokens = 4096
) )
// DisambiguationConfig configures the third-party disambiguation // DisambiguationAgentConfig configures the third-party disambiguation
// agent. The agent has no DB tools and no web-search tools: the // agent. The agent has no DB tools and no web-search tools: the
// candidate list is supplied entirely in the prompt and the agent // candidate list is supplied entirely in the prompt and the agent
// only picks among it. // only picks among it.
@@ -62,7 +62,7 @@ const (
// MaxTokens and Temperature bound and steer the single LLM call, and // MaxTokens and Temperature bound and steer the single LLM call, and
// Timeout caps a single run. Zero-valued fields fall back to package // Timeout caps a single run. Zero-valued fields fall back to package
// defaults. // defaults.
type DisambiguationConfig struct { type DisambiguationAgentConfig struct {
LLMClient *llm.Client LLMClient *llm.Client
Model string Model string
MaxTokens *int MaxTokens *int
@@ -84,7 +84,7 @@ type DisambiguationResult struct {
// no tools: the candidate list is supplied in the prompt and the // no tools: the candidate list is supplied in the prompt and the
// agent must only choose among it. // agent must only choose among it.
func BuildDisambiguationAgent( func BuildDisambiguationAgent(
cfg DisambiguationConfig, cfg DisambiguationAgentConfig,
logger *log.Logger, logger *log.Logger,
) *agent.Agent { ) *agent.Agent {
outputType, err := agent.NewOutputType[DisambiguationResult]("third_party_disambiguation") outputType, err := agent.NewOutputType[DisambiguationResult]("third_party_disambiguation")