ci / test (push) Failing after 3m24s
BREAKING: LLMRouter now requires explicit LLMProvider
New Abstractions:
- LLMProvider interface: swap providers (OpenAI, Claude, local, etc)
- SpecBuilder interface: custom spec generation strategies
- ParameterBinder interface: flexible parameter resolution
- ActivityExecutor interface: pluggable activity execution
- WorkflowValidator interface: composable validation
Provider System:
- ProviderRegistry: manage multiple LLM providers
- RoutingProviderLLM: fallback across providers
- CachingLLMProvider: caching wrapper
- RetryingLLMProvider: retry wrapper
Spec Building:
- DefaultSpecBuilder: basic spec generation
- CronSpecBuilder: cron workflow specialization
- SpecBuilderFactory: builder selection
- CompositeSpecBuilder: multi-strategy fallback
- BuildMetadata: context for builders
Validators:
- StateGraphValidator: DAG structure
- ActivityAvailabilityValidator: activity existence
- TimeoutValidator: timeout format
- CompositeValidator: multiple validators
- TransitionValidator: state transitions
Refactored Components:
- LLMRouter: config-driven, provider-agnostic
- LLMClient: now implements LLMProvider
- llm_router.go: 97 fewer lines (delegated to builders)
Migration Path:
OLD: NewLLMRouter(kb)
NEW: NewLLMRouter(LLMRouterConfig{Provider: ..., KB: ...})
552 lines
15 KiB
Go
552 lines
15 KiB
Go
package routing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// LLMRouterInput is input to the llm-router activity
|
|
type LLMRouterInput struct {
|
|
Message string `json:"message"`
|
|
Context map[string]interface{} `json:"context,omitempty"` // Optional context (repo, branch, etc)
|
|
MemoryContext *MemoryContext `json:"memoryContext,omitempty"` // Optional memory retrieval results
|
|
UseMemory bool `json:"useMemory,omitempty"` // Enable memory retrieval (default: false)
|
|
}
|
|
|
|
// MemoryContext holds retrieved memory for prompt injection
|
|
type MemoryContext struct {
|
|
Skills []MemorySkill `json:"skills"`
|
|
Lessons []MemoryLesson `json:"lessons"`
|
|
References []MemoryReference `json:"references"`
|
|
}
|
|
|
|
// MemorySkill from memory service
|
|
type MemorySkill struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
// MemoryLesson from memory service
|
|
type MemoryLesson struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text"`
|
|
Level string `json:"level"`
|
|
}
|
|
|
|
// MemoryReference from memory service
|
|
type MemoryReference struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// LLMRouterOutput is output from the llm-router activity
|
|
type LLMRouterOutput struct {
|
|
Spec *WorkflowSpec `json:"spec,omitempty"`
|
|
CronSpec *CronWorkflowSpec `json:"cronSpec,omitempty"`
|
|
IsCron bool `json:"isCron"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// LLMRouter orchestrates intent analysis and spec generation
|
|
type LLMRouter struct {
|
|
provider LLMProvider
|
|
knowledgeBase *KnowledgeBase
|
|
specBuilder SpecBuilder
|
|
validators []WorkflowValidator
|
|
paramBinder ParameterBinder
|
|
promptTemplate PromptTemplate
|
|
}
|
|
|
|
// LLMRouterConfig configures the router
|
|
type LLMRouterConfig struct {
|
|
Provider LLMProvider
|
|
KnowledgeBase *KnowledgeBase
|
|
SpecBuilder SpecBuilder
|
|
Validators []WorkflowValidator
|
|
ParamBinder ParameterBinder
|
|
}
|
|
|
|
// NewLLMRouter creates a new LLM router with custom config
|
|
func NewLLMRouter(config LLMRouterConfig) (*LLMRouter, error) {
|
|
if config.Provider == nil {
|
|
return nil, fmt.Errorf("provider is required")
|
|
}
|
|
if config.KnowledgeBase == nil {
|
|
return nil, fmt.Errorf("knowledge base is required")
|
|
}
|
|
|
|
router := &LLMRouter{
|
|
provider: config.Provider,
|
|
knowledgeBase: config.KnowledgeBase,
|
|
}
|
|
|
|
// Set defaults
|
|
if config.SpecBuilder == nil {
|
|
router.specBuilder = NewDefaultSpecBuilder(config.KnowledgeBase)
|
|
} else {
|
|
router.specBuilder = config.SpecBuilder
|
|
}
|
|
|
|
if config.ParamBinder == nil {
|
|
router.paramBinder = NewDefaultParameterBinder()
|
|
} else {
|
|
router.paramBinder = config.ParamBinder
|
|
}
|
|
|
|
router.validators = config.Validators
|
|
if len(router.validators) == 0 {
|
|
router.validators = []WorkflowValidator{
|
|
&StateGraphValidator{},
|
|
NewActivityAvailabilityValidator(config.KnowledgeBase),
|
|
&TimeoutValidator{},
|
|
}
|
|
}
|
|
|
|
return router, nil
|
|
}
|
|
|
|
// NewLLMRouterDefault creates router with default HTTP provider
|
|
func NewLLMRouterDefault(kb *KnowledgeBase) (*LLMRouter, error) {
|
|
client := NewLLMClient()
|
|
return NewLLMRouter(LLMRouterConfig{
|
|
Provider: client,
|
|
KnowledgeBase: kb,
|
|
})
|
|
}
|
|
|
|
// Route analyzes user message and generates appropriate workflow spec
|
|
func (r *LLMRouter) Route(ctx context.Context, input LLMRouterInput) (*LLMRouterOutput, error) {
|
|
// 1. Analyze intent using LLM provider
|
|
intent, err := r.analyzeIntent(ctx, input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("intent analysis failed: %w", err)
|
|
}
|
|
|
|
// 2. Build workflow spec based on intent
|
|
metadata := &BuildMetadata{
|
|
KnowledgeBase: r.knowledgeBase,
|
|
Context: input.Context,
|
|
Validators: r.validators,
|
|
}
|
|
|
|
spec, err := r.specBuilder.FromIntent(intent, metadata)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spec build failed: %w", err)
|
|
}
|
|
|
|
return &LLMRouterOutput{
|
|
Spec: spec,
|
|
IsCron: intent.IsCron,
|
|
}, nil
|
|
}
|
|
|
|
// Intent represents analyzed user intent
|
|
type Intent struct {
|
|
Activities []string `json:"activities"` // Selected activity names
|
|
Parameters map[string]interface{} `json:"parameters"` // Extracted parameters
|
|
IsCron bool `json:"isCron"` // Is scheduled workflow?
|
|
CronSchedule string `json:"cronSchedule"` // Cron expression if scheduled
|
|
CronTimezone string `json:"cronTimezone"` // Timezone for cron
|
|
WorkflowName string `json:"workflowName"` // Generated workflow name
|
|
ErrorHandling string `json:"errorHandling"` // "retry", "fail-fast", "continue"
|
|
}
|
|
|
|
// analyzeIntent uses LLM provider to understand user request
|
|
func (r *LLMRouter) analyzeIntent(ctx context.Context, input LLMRouterInput) (*Intent, error) {
|
|
// Build prompt with knowledge base context
|
|
userPrompt := r.buildIntentPrompt(input)
|
|
|
|
// Call LLM provider
|
|
response, err := r.provider.Chat(ctx, intentSystemPrompt, userPrompt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LLM provider failed: %w", err)
|
|
}
|
|
|
|
// Parse LLM response
|
|
intent, err := parseIntentResponse(response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse intent: %w", err)
|
|
}
|
|
|
|
// Validate activities exist
|
|
for _, actName := range intent.Activities {
|
|
if !r.knowledgeBase.HasActivity(actName) {
|
|
return nil, fmt.Errorf("unknown activity: %s", actName)
|
|
}
|
|
}
|
|
|
|
return intent, nil
|
|
}
|
|
|
|
// buildIntentPrompt creates the prompt for intent analysis
|
|
func (r *LLMRouter) buildIntentPrompt(input LLMRouterInput) string {
|
|
// Get activity summaries
|
|
var activityList strings.Builder
|
|
for _, act := range r.knowledgeBase.Activities {
|
|
activityList.WriteString(fmt.Sprintf("- %s: %s (category: %s, timeout: %s, flaky: %v)\n",
|
|
act.Name, act.Description, act.Category,
|
|
act.Constraints.DefaultTimeout, act.Constraints.IsFlaky))
|
|
}
|
|
|
|
// Build context string
|
|
contextStr := ""
|
|
if len(input.Context) > 0 {
|
|
ctxBytes, _ := json.Marshal(input.Context)
|
|
contextStr = fmt.Sprintf("\nProvided context: %s", string(ctxBytes))
|
|
}
|
|
|
|
// Build memory context string
|
|
memoryStr := r.formatMemoryContext(input.MemoryContext)
|
|
|
|
return fmt.Sprintf(`User request: %s
|
|
%s%s
|
|
Available activities:
|
|
%s
|
|
Analyze the request and output JSON with:
|
|
- activities: ordered list of activity names to execute
|
|
- parameters: extracted parameters from request (repo URL, branch, etc)
|
|
- isCron: true if user wants scheduled/recurring execution
|
|
- cronSchedule: cron expression if scheduled (e.g., "0 2 * * *" for 2 AM daily)
|
|
- cronTimezone: timezone (default "UTC")
|
|
- workflowName: short descriptive name
|
|
- errorHandling: "retry" (default), "fail-fast", or "continue"
|
|
|
|
Output ONLY valid JSON, no explanation.`, input.Message, contextStr, memoryStr, activityList.String())
|
|
}
|
|
|
|
// formatMemoryContext formats memory context for prompt injection
|
|
func (r *LLMRouter) formatMemoryContext(mem *MemoryContext) string {
|
|
if mem == nil {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
if len(mem.Skills) > 0 {
|
|
sb.WriteString("\n\nRelevant skills from memory:\n")
|
|
for _, skill := range mem.Skills {
|
|
if skill.Why != "" {
|
|
sb.WriteString(fmt.Sprintf("- %s: %s (reason: %s)\n", skill.Name, skill.Description, skill.Why))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("- %s: %s\n", skill.Name, skill.Description))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(mem.Lessons) > 0 {
|
|
sb.WriteString("\nRelevant knowledge from memory:\n")
|
|
for _, lesson := range mem.Lessons {
|
|
text := lesson.Text
|
|
if len(text) > 300 {
|
|
text = text[:300] + "..."
|
|
}
|
|
sb.WriteString(fmt.Sprintf("- [%s] %s\n", lesson.Level, text))
|
|
}
|
|
}
|
|
|
|
if len(mem.References) > 0 {
|
|
sb.WriteString("\nReference documents:\n")
|
|
for _, ref := range mem.References {
|
|
text := ref.Text
|
|
if len(text) > 200 {
|
|
text = text[:200] + "..."
|
|
}
|
|
sb.WriteString(fmt.Sprintf("- %s\n", text))
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// parseIntentResponse extracts Intent from LLM response
|
|
func parseIntentResponse(response string) (*Intent, error) {
|
|
// Try to extract JSON from response
|
|
response = strings.TrimSpace(response)
|
|
|
|
// Handle markdown code blocks
|
|
if strings.HasPrefix(response, "```") {
|
|
re := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
|
matches := re.FindStringSubmatch(response)
|
|
if len(matches) > 1 {
|
|
response = strings.TrimSpace(matches[1])
|
|
}
|
|
}
|
|
|
|
var intent Intent
|
|
if err := json.Unmarshal([]byte(response), &intent); err != nil {
|
|
return nil, fmt.Errorf("invalid JSON from LLM: %w\nResponse: %s", err, response)
|
|
}
|
|
|
|
// Set defaults
|
|
if intent.CronTimezone == "" {
|
|
intent.CronTimezone = "UTC"
|
|
}
|
|
if intent.ErrorHandling == "" {
|
|
intent.ErrorHandling = "retry"
|
|
}
|
|
if intent.WorkflowName == "" {
|
|
intent.WorkflowName = "generated-workflow"
|
|
}
|
|
|
|
return &intent, nil
|
|
}
|
|
|
|
// buildSpec creates WorkflowSpec from intent
|
|
func (r *LLMRouter) buildSpec(intent *Intent, input LLMRouterInput) (*WorkflowSpec, error) {
|
|
if len(intent.Activities) == 0 {
|
|
return nil, fmt.Errorf("no activities selected")
|
|
}
|
|
|
|
states := make([]State, 0, len(intent.Activities)+1)
|
|
|
|
// Build states for each activity
|
|
for i, actName := range intent.Activities {
|
|
act := r.knowledgeBase.GetActivity(actName)
|
|
|
|
state := State{
|
|
Name: actName,
|
|
Type: StateTypeTask,
|
|
Resource: actName,
|
|
Parameters: r.buildParameters(act, intent, i),
|
|
Timeout: act.Constraints.DefaultTimeout,
|
|
Retry: r.buildRetryPolicy(act, intent),
|
|
}
|
|
|
|
// Set next state or end
|
|
if i < len(intent.Activities)-1 {
|
|
state.Next = intent.Activities[i+1]
|
|
} else {
|
|
state.End = true
|
|
}
|
|
|
|
// Add catch clause for flaky activities
|
|
if act.Constraints.IsFlaky && intent.ErrorHandling != "fail-fast" {
|
|
state.Catch = []CatchClause{
|
|
{
|
|
ErrorEquals: []string{"ActivityError", "TimeoutError"},
|
|
Next: "HandleError",
|
|
},
|
|
}
|
|
}
|
|
|
|
states = append(states, state)
|
|
}
|
|
|
|
// Add error handler if needed
|
|
hasFlaky := false
|
|
for _, actName := range intent.Activities {
|
|
act := r.knowledgeBase.GetActivity(actName)
|
|
if act != nil && act.Constraints.IsFlaky {
|
|
hasFlaky = true
|
|
break
|
|
}
|
|
}
|
|
if hasFlaky && intent.ErrorHandling != "fail-fast" {
|
|
states = append(states, State{
|
|
Name: "HandleError",
|
|
Type: StateTypeFail,
|
|
Error: "WorkflowError",
|
|
Cause: "Activity failed after retries",
|
|
})
|
|
}
|
|
|
|
// Build input map
|
|
inputMap := make(map[string]interface{})
|
|
for k, v := range intent.Parameters {
|
|
inputMap[k] = v
|
|
}
|
|
for k, v := range input.Context {
|
|
if _, exists := inputMap[k]; !exists {
|
|
inputMap[k] = v
|
|
}
|
|
}
|
|
|
|
return &WorkflowSpec{
|
|
Name: intent.WorkflowName,
|
|
Input: inputMap,
|
|
States: states,
|
|
}, nil
|
|
}
|
|
|
|
// getStringFromMap safely extracts a string from a map
|
|
func getStringFromMap(m map[string]interface{}, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstNonEmpty returns the first non-empty string from the list
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildCronSpec creates CronWorkflowSpec from intent
|
|
func (r *LLMRouter) buildCronSpec(intent *Intent, input LLMRouterInput) (*CronWorkflowSpec, error) {
|
|
spec, err := r.buildSpec(intent, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract schedule from multiple sources
|
|
schedule := firstNonEmpty(
|
|
intent.CronSchedule,
|
|
getStringFromMap(intent.Parameters, "cronSchedule"),
|
|
getStringFromMap(spec.Input, "cronSchedule"),
|
|
)
|
|
|
|
// Extract timezone from multiple sources, default to UTC
|
|
timezone := firstNonEmpty(
|
|
intent.CronTimezone,
|
|
getStringFromMap(intent.Parameters, "cronTimezone"),
|
|
getStringFromMap(spec.Input, "cronTimezone"),
|
|
"UTC",
|
|
)
|
|
|
|
// Clean cron fields from input
|
|
delete(spec.Input, "cronSchedule")
|
|
delete(spec.Input, "cronTimezone")
|
|
|
|
return &CronWorkflowSpec{
|
|
Name: spec.Name,
|
|
Type: "CronWorkflow",
|
|
Schedule: schedule,
|
|
Timezone: timezone,
|
|
Input: spec.Input,
|
|
States: spec.States,
|
|
MaxConcurrent: 1,
|
|
Timeout: "1h",
|
|
EnableHistory: true,
|
|
}, nil
|
|
}
|
|
|
|
// isCommonInputField checks if field name is a common workflow input
|
|
func isCommonInputField(name string) bool {
|
|
switch name {
|
|
case "repo", "path", "branch":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// paramResolver resolves activity parameters from multiple sources
|
|
type paramResolver struct {
|
|
intent *Intent
|
|
kb *KnowledgeBase
|
|
prevState string
|
|
}
|
|
|
|
// resolve finds parameter value from intent, previous output, default, or input ref
|
|
func (r *paramResolver) resolve(inputName string, inputDef InputField) interface{} {
|
|
// 1. From intent parameters
|
|
if val, ok := r.intent.Parameters[inputName]; ok {
|
|
return val
|
|
}
|
|
|
|
// 2. From previous state output
|
|
if val := r.fromPrevOutput(inputName); val != nil {
|
|
return val
|
|
}
|
|
|
|
// 3. Default value
|
|
if inputDef.Default != nil {
|
|
return inputDef.Default
|
|
}
|
|
|
|
// 4. Input reference for common fields
|
|
if isCommonInputField(inputName) {
|
|
return fmt.Sprintf("${input.%s}", inputName)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fromPrevOutput checks if previous activity has matching output
|
|
func (r *paramResolver) fromPrevOutput(inputName string) interface{} {
|
|
if r.prevState == "" {
|
|
return nil
|
|
}
|
|
prevActDef := r.kb.GetActivity(r.prevState)
|
|
if prevActDef == nil {
|
|
return nil
|
|
}
|
|
for outName := range prevActDef.Outputs {
|
|
if outName == inputName || strings.EqualFold(outName, inputName) {
|
|
return fmt.Sprintf("${%s.output.%s}", r.prevState, outName)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildParameters creates parameter map for activity
|
|
func (r *LLMRouter) buildParameters(act *ActivityMetadata, intent *Intent, stateIndex int) map[string]interface{} {
|
|
var prevState string
|
|
if stateIndex > 0 {
|
|
prevState = intent.Activities[stateIndex-1]
|
|
}
|
|
|
|
resolver := ¶mResolver{
|
|
intent: intent,
|
|
kb: r.knowledgeBase,
|
|
prevState: prevState,
|
|
}
|
|
|
|
params := make(map[string]interface{})
|
|
for name, def := range act.Inputs {
|
|
if val := resolver.resolve(name, def); val != nil {
|
|
params[name] = val
|
|
}
|
|
}
|
|
return params
|
|
}
|
|
|
|
// buildRetryPolicy creates retry policy based on activity constraints
|
|
func (r *LLMRouter) buildRetryPolicy(act *ActivityMetadata, intent *Intent) *RetryPolicy {
|
|
if intent.ErrorHandling == "fail-fast" {
|
|
return &RetryPolicy{
|
|
MaxAttempts: 1,
|
|
BackoffRate: 1.0,
|
|
InitialInterval: "1s",
|
|
}
|
|
}
|
|
|
|
return &RetryPolicy{
|
|
MaxAttempts: int32(act.Constraints.RecommendedRetries),
|
|
BackoffRate: act.Constraints.RetryBackoff,
|
|
InitialInterval: "1s",
|
|
MaxInterval: "30s",
|
|
}
|
|
}
|
|
|
|
const intentSystemPrompt = `You are an intelligent workflow router. Your job is to:
|
|
1. Understand what the user wants to accomplish
|
|
2. Select the appropriate activities from the available list
|
|
3. Order them correctly based on dependencies
|
|
4. Extract any parameters mentioned (URLs, branches, etc)
|
|
5. Detect if user wants scheduled/recurring execution
|
|
6. Use any relevant knowledge from memory to inform your decisions
|
|
|
|
Rules:
|
|
- Always include CloneRepoActivity first if any analysis activity is needed
|
|
- Order activities respecting dependencies
|
|
- If user mentions "daily", "every hour", "weekly", etc → set isCron=true and cronSchedule
|
|
- Common cron patterns: "0 2 * * *" (2 AM daily), "0 * * * *" (hourly), "0 0 * * 0" (weekly Sunday)
|
|
- Extract repo URLs, branch names, severity levels from the message
|
|
- workflowName should be short and descriptive (kebab-case)
|
|
- If memory context includes relevant skills or lessons, incorporate that knowledge
|
|
- Skills from memory may suggest specific activity parameters or ordering
|
|
|
|
Output ONLY valid JSON.`
|