- Add RoutingWorkflow: generic state machine executor for WorkflowSpec - Add LLM Router: natural language → WorkflowSpec generation - Add RetrieveMemoryActivity: query poimen-memory for context - Add activities: AnalyzeCode, SecurityScan, GenerateReport, Notify, etc. - Add agent-prompts/router: LLM prompt documentation - Extend starter with --route flag for routing workflows - Remove orchestrator job (trigger via API/message instead) - Clean up: move docs to Desktop, add .gitignore for *.md
This commit is contained in:
@@ -341,10 +341,76 @@
|
||||
"dependencies": [],
|
||||
"notes": "Network-dependent. May fail on network issues or service throttling. Retry 2x."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "RetrieveMemoryActivity",
|
||||
"description": "Retrieve relevant knowledge, skills, and lessons from poimen-memory semantic search",
|
||||
"category": "memory",
|
||||
"inputs": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Semantic search query",
|
||||
"required": true
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Memory project (default: poimen)",
|
||||
"required": false,
|
||||
"default": "poimen"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "Retrieval scope: skills, lessons, references, all",
|
||||
"required": false,
|
||||
"default": "all"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results to return",
|
||||
"required": false,
|
||||
"default": 10
|
||||
},
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"description": "Tool context for skill matching",
|
||||
"required": false
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "Task description for context retrieval",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"description": "Relevant skills found"
|
||||
},
|
||||
"lessons": {
|
||||
"type": "array",
|
||||
"description": "Relevant lessons/knowledge found"
|
||||
},
|
||||
"references": {
|
||||
"type": "array",
|
||||
"description": "Reference documents found"
|
||||
},
|
||||
"totalResults": {
|
||||
"type": "integer",
|
||||
"description": "Total results found"
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"defaultTimeout": "30s",
|
||||
"isFlaky": true,
|
||||
"recommendedRetries": 2,
|
||||
"retryBackoff": 1.5,
|
||||
"dependencies": [],
|
||||
"notes": "Network-dependent. First activity to run for context-aware routing. Fast timeout."
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"totalActivities": 8,
|
||||
"totalActivities": 9,
|
||||
"lastUpdated": "2025-08-31T00:00:00Z",
|
||||
"categories": {
|
||||
"repository": 1,
|
||||
@@ -354,7 +420,8 @@
|
||||
"deployment": 1,
|
||||
"notification": 1,
|
||||
"approval": 1,
|
||||
"storage": 1
|
||||
"storage": 1,
|
||||
"memory": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// KnowledgeBase represents the activity knowledge base
|
||||
@@ -72,6 +73,21 @@ func LoadKnowledgeBaseFromDefaultPath() (*KnowledgeBase, error) {
|
||||
return LoadKnowledgeBase("internal/routing/activity_knowledge_base.json")
|
||||
}
|
||||
|
||||
// Try from parent directory (for tests running from tests/ dir)
|
||||
if _, err := os.Stat("../internal/routing/activity_knowledge_base.json"); err == nil {
|
||||
return LoadKnowledgeBase("../internal/routing/activity_knowledge_base.json")
|
||||
}
|
||||
|
||||
// Try using runtime to find package directory
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if ok {
|
||||
pkgDir := filepath.Dir(filename)
|
||||
path := filepath.Join(pkgDir, "activity_knowledge_base.json")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return LoadKnowledgeBase(path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("activity_knowledge_base.json not found in any expected location")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
// llmBaseURL is the base URL for the LLM API
|
||||
llmBaseURL string
|
||||
)
|
||||
|
||||
func init() {
|
||||
llmBaseURL = os.Getenv("LOCAL_LLM_BASE_URL")
|
||||
if llmBaseURL == "" {
|
||||
llmBaseURL = "https://api.riotpiao.com"
|
||||
}
|
||||
}
|
||||
|
||||
// LLMClient is a simple LLM client for routing
|
||||
type LLMClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewLLMClient creates a new LLM client
|
||||
func NewLLMClient() *LLMClient {
|
||||
return &LLMClient{
|
||||
baseURL: llmBaseURL,
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// llmRequest is the request body for the OpenAI-compatible API
|
||||
type llmRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []llmMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type llmMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// llmResponse is the response from the OpenAI-compatible API
|
||||
type llmResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// Chat sends a chat completion request
|
||||
func (c *LLMClient) Chat(ctx context.Context, systemPrompt, userMessage string) (string, error) {
|
||||
req := llmRequest{
|
||||
Model: "reasoning",
|
||||
Messages: []llmMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userMessage},
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST",
|
||||
fmt.Sprintf("%s/v1/chat/completions", c.baseURL),
|
||||
bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to connect to LLM API at %s: %w", c.baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("LLM API returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var respObj llmResponse
|
||||
if err := json.Unmarshal(respBody, &respObj); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if len(respObj.Choices) == 0 {
|
||||
return "", fmt.Errorf("no choices in response from LLM API")
|
||||
}
|
||||
|
||||
return respObj.Choices[0].Message.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
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 {
|
||||
client *LLMClient
|
||||
knowledgeBase *KnowledgeBase
|
||||
}
|
||||
|
||||
// NewLLMRouter creates a new LLM router
|
||||
func NewLLMRouter(kb *KnowledgeBase) (*LLMRouter, error) {
|
||||
return &LLMRouter{
|
||||
client: NewLLMClient(),
|
||||
knowledgeBase: kb,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
if intent.IsCron {
|
||||
cronSpec, err := r.buildCronSpec(intent, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cron spec build failed: %w", err)
|
||||
}
|
||||
return &LLMRouterOutput{
|
||||
CronSpec: cronSpec,
|
||||
IsCron: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
spec, err := r.buildSpec(intent, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spec build failed: %w", err)
|
||||
}
|
||||
|
||||
return &LLMRouterOutput{
|
||||
Spec: spec,
|
||||
IsCron: false,
|
||||
}, 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 to understand user request
|
||||
func (r *LLMRouter) analyzeIntent(ctx context.Context, input LLMRouterInput) (*Intent, error) {
|
||||
// Build prompt with knowledge base context
|
||||
prompt := r.buildIntentPrompt(input)
|
||||
|
||||
// Call LLM
|
||||
response, err := r.client.Chat(ctx, intentSystemPrompt, prompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM call 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
|
||||
}
|
||||
|
||||
// buildCronSpec creates CronWorkflowSpec from intent
|
||||
func (r *LLMRouter) buildCronSpec(intent *Intent, input LLMRouterInput) (*CronWorkflowSpec, error) {
|
||||
// First build regular spec
|
||||
spec, err := r.buildSpec(intent, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get schedule - check both intent and parameters (LLM sometimes puts it in parameters)
|
||||
schedule := intent.CronSchedule
|
||||
if schedule == "" {
|
||||
if sched, ok := intent.Parameters["cronSchedule"].(string); ok {
|
||||
schedule = sched
|
||||
}
|
||||
}
|
||||
if schedule == "" {
|
||||
if sched, ok := spec.Input["cronSchedule"].(string); ok {
|
||||
schedule = sched
|
||||
delete(spec.Input, "cronSchedule") // Remove from input
|
||||
}
|
||||
}
|
||||
|
||||
// Get timezone
|
||||
timezone := intent.CronTimezone
|
||||
if timezone == "" {
|
||||
if tz, ok := intent.Parameters["cronTimezone"].(string); ok {
|
||||
timezone = tz
|
||||
}
|
||||
}
|
||||
if timezone == "" {
|
||||
if tz, ok := spec.Input["cronTimezone"].(string); ok {
|
||||
timezone = tz
|
||||
delete(spec.Input, "cronTimezone") // Remove from input
|
||||
}
|
||||
}
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
|
||||
return &CronWorkflowSpec{
|
||||
Name: spec.Name,
|
||||
Type: "CronWorkflow",
|
||||
Schedule: schedule,
|
||||
Timezone: timezone,
|
||||
Input: spec.Input,
|
||||
States: spec.States,
|
||||
MaxConcurrent: 1,
|
||||
Timeout: "1h",
|
||||
EnableHistory: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildParameters creates parameter map for activity
|
||||
func (r *LLMRouter) buildParameters(act *ActivityMetadata, intent *Intent, stateIndex int) map[string]interface{} {
|
||||
params := make(map[string]interface{})
|
||||
|
||||
for inputName, inputDef := range act.Inputs {
|
||||
// Check if parameter was extracted from intent
|
||||
if val, ok := intent.Parameters[inputName]; ok {
|
||||
params[inputName] = val
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for JSONPath reference from previous state
|
||||
if stateIndex > 0 {
|
||||
prevAct := intent.Activities[stateIndex-1]
|
||||
prevActDef := r.knowledgeBase.GetActivity(prevAct)
|
||||
|
||||
// Look for matching output from previous activity
|
||||
for outName := range prevActDef.Outputs {
|
||||
if outName == inputName || strings.EqualFold(outName, inputName) {
|
||||
params[inputName] = fmt.Sprintf("${%s.output.%s}", prevAct, outName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use default if available
|
||||
if params[inputName] == nil && inputDef.Default != nil {
|
||||
params[inputName] = inputDef.Default
|
||||
}
|
||||
|
||||
// Use input reference for common fields
|
||||
if params[inputName] == nil {
|
||||
if inputName == "repo" || inputName == "path" || inputName == "branch" {
|
||||
params[inputName] = fmt.Sprintf("${input.%s}", inputName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.`
|
||||
@@ -0,0 +1,103 @@
|
||||
// +build integration
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLLMRouterIntegration tests against real api.riotpiao.com
|
||||
// Run with: go test -tags=integration -v -run TestLLMRouterIntegration
|
||||
func TestLLMRouterIntegration(t *testing.T) {
|
||||
// Skip if not explicitly enabled
|
||||
if os.Getenv("RUN_INTEGRATION_TESTS") != "1" {
|
||||
t.Skip("Skipping integration test. Set RUN_INTEGRATION_TESTS=1 to run.")
|
||||
}
|
||||
|
||||
kb, err := LoadKnowledgeBaseFromDefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load knowledge base: %v", err)
|
||||
}
|
||||
|
||||
router, err := NewLLMRouter(kb)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create router: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input LLMRouterInput
|
||||
validate func(*testing.T, *LLMRouterOutput)
|
||||
}{
|
||||
{
|
||||
name: "analyze repo request",
|
||||
input: LLMRouterInput{
|
||||
Message: "Analyze the GitHub repo https://github.com/rockliang/poimen for code quality and security issues",
|
||||
Context: map[string]interface{}{
|
||||
"branch": "main",
|
||||
},
|
||||
},
|
||||
validate: func(t *testing.T, output *LLMRouterOutput) {
|
||||
if output.IsCron {
|
||||
t.Error("expected one-time workflow, not cron")
|
||||
}
|
||||
if output.Spec == nil {
|
||||
t.Fatal("expected spec, got nil")
|
||||
}
|
||||
if len(output.Spec.States) < 2 {
|
||||
t.Errorf("expected at least 2 states, got %d", len(output.Spec.States))
|
||||
}
|
||||
// Should start with CloneRepoActivity
|
||||
if output.Spec.States[0].Resource != "CloneRepoActivity" {
|
||||
t.Errorf("expected first activity to be CloneRepoActivity, got %s", output.Spec.States[0].Resource)
|
||||
}
|
||||
t.Logf("Generated workflow: %s with %d states", output.Spec.Name, len(output.Spec.States))
|
||||
for i, state := range output.Spec.States {
|
||||
t.Logf(" State %d: %s (%s)", i, state.Name, state.Resource)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "daily security scan (cron)",
|
||||
input: LLMRouterInput{
|
||||
Message: "Run a security scan on https://github.com/rockliang/poimen every day at 3 AM UTC",
|
||||
},
|
||||
validate: func(t *testing.T, output *LLMRouterOutput) {
|
||||
if !output.IsCron {
|
||||
t.Error("expected cron workflow")
|
||||
}
|
||||
if output.CronSpec == nil {
|
||||
t.Fatal("expected cron spec, got nil")
|
||||
}
|
||||
if output.CronSpec.Schedule == "" {
|
||||
t.Error("expected cron schedule")
|
||||
}
|
||||
t.Logf("Generated cron workflow: %s, schedule: %s", output.CronSpec.Name, output.CronSpec.Schedule)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
output, err := router.Route(ctx, tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Route failed: %v", err)
|
||||
}
|
||||
|
||||
// Pretty print output
|
||||
jsonOut, _ := json.MarshalIndent(output, "", " ")
|
||||
t.Logf("Output:\n%s", string(jsonOut))
|
||||
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseIntentResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
wantErr bool
|
||||
validate func(*testing.T, *Intent)
|
||||
}{
|
||||
{
|
||||
name: "basic intent",
|
||||
response: `{
|
||||
"activities": ["CloneRepoActivity", "AnalyzeCodeActivity"],
|
||||
"parameters": {"repo": "https://github.com/test/repo"},
|
||||
"isCron": false,
|
||||
"workflowName": "analyze-repo"
|
||||
}`,
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, intent *Intent) {
|
||||
if len(intent.Activities) != 2 {
|
||||
t.Errorf("expected 2 activities, got %d", len(intent.Activities))
|
||||
}
|
||||
if intent.Activities[0] != "CloneRepoActivity" {
|
||||
t.Errorf("expected CloneRepoActivity first, got %s", intent.Activities[0])
|
||||
}
|
||||
if intent.IsCron {
|
||||
t.Error("expected isCron=false")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cron intent",
|
||||
response: `{
|
||||
"activities": ["CloneRepoActivity", "SecurityScanActivity"],
|
||||
"parameters": {"repo": "https://github.com/test/repo"},
|
||||
"isCron": true,
|
||||
"cronSchedule": "0 2 * * *",
|
||||
"cronTimezone": "America/New_York",
|
||||
"workflowName": "daily-security-scan"
|
||||
}`,
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, intent *Intent) {
|
||||
if !intent.IsCron {
|
||||
t.Error("expected isCron=true")
|
||||
}
|
||||
if intent.CronSchedule != "0 2 * * *" {
|
||||
t.Errorf("expected cron schedule '0 2 * * *', got %s", intent.CronSchedule)
|
||||
}
|
||||
if intent.CronTimezone != "America/New_York" {
|
||||
t.Errorf("expected timezone 'America/New_York', got %s", intent.CronTimezone)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with markdown code block",
|
||||
response: "```json\n{\"activities\": [\"CloneRepoActivity\"], \"parameters\": {}, \"isCron\": false}\n```",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, intent *Intent) {
|
||||
if len(intent.Activities) != 1 {
|
||||
t.Errorf("expected 1 activity, got %d", len(intent.Activities))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "defaults applied",
|
||||
response: `{"activities": ["CloneRepoActivity"], "parameters": {}}`,
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, intent *Intent) {
|
||||
if intent.CronTimezone != "UTC" {
|
||||
t.Errorf("expected default timezone UTC, got %s", intent.CronTimezone)
|
||||
}
|
||||
if intent.ErrorHandling != "retry" {
|
||||
t.Errorf("expected default errorHandling 'retry', got %s", intent.ErrorHandling)
|
||||
}
|
||||
if intent.WorkflowName != "generated-workflow" {
|
||||
t.Errorf("expected default workflowName, got %s", intent.WorkflowName)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
response: "this is not json",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
intent, err := parseIntentResponse(tt.response)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, intent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSpec(t *testing.T) {
|
||||
// Load knowledge base
|
||||
kb, err := LoadKnowledgeBaseFromDefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load knowledge base: %v", err)
|
||||
}
|
||||
|
||||
router := &LLMRouter{
|
||||
knowledgeBase: kb,
|
||||
}
|
||||
|
||||
intent := &Intent{
|
||||
Activities: []string{"CloneRepoActivity", "AnalyzeCodeActivity", "SecurityScanActivity"},
|
||||
Parameters: map[string]interface{}{"repo": "https://github.com/test/repo", "branch": "main"},
|
||||
WorkflowName: "test-workflow",
|
||||
ErrorHandling: "retry",
|
||||
}
|
||||
|
||||
input := LLMRouterInput{
|
||||
Message: "Analyze repo for security",
|
||||
Context: map[string]interface{}{},
|
||||
}
|
||||
|
||||
spec, err := router.buildSpec(intent, input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildSpec failed: %v", err)
|
||||
}
|
||||
|
||||
// Validate spec
|
||||
if spec.Name != "test-workflow" {
|
||||
t.Errorf("expected name 'test-workflow', got %s", spec.Name)
|
||||
}
|
||||
|
||||
if len(spec.States) < 3 {
|
||||
t.Errorf("expected at least 3 states, got %d", len(spec.States))
|
||||
}
|
||||
|
||||
// First state should be CloneRepoActivity
|
||||
if spec.States[0].Resource != "CloneRepoActivity" {
|
||||
t.Errorf("expected first state to be CloneRepoActivity, got %s", spec.States[0].Resource)
|
||||
}
|
||||
|
||||
// Last activity state should have End=true
|
||||
lastActivityIdx := len(spec.States) - 1
|
||||
if spec.States[lastActivityIdx].Type == StateTypeFail {
|
||||
lastActivityIdx--
|
||||
}
|
||||
if !spec.States[lastActivityIdx].End {
|
||||
t.Error("expected last activity state to have End=true")
|
||||
}
|
||||
|
||||
// Check retry policy on flaky activity (AnalyzeCodeActivity)
|
||||
for _, state := range spec.States {
|
||||
if state.Resource == "AnalyzeCodeActivity" {
|
||||
if state.Retry == nil {
|
||||
t.Error("expected retry policy on flaky activity")
|
||||
} else if state.Retry.MaxAttempts != 3 {
|
||||
t.Errorf("expected 3 max attempts for flaky activity, got %d", state.Retry.MaxAttempts)
|
||||
}
|
||||
if len(state.Catch) == 0 {
|
||||
t.Error("expected catch clause on flaky activity")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCronSpec(t *testing.T) {
|
||||
kb, err := LoadKnowledgeBaseFromDefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load knowledge base: %v", err)
|
||||
}
|
||||
|
||||
router := &LLMRouter{
|
||||
knowledgeBase: kb,
|
||||
}
|
||||
|
||||
intent := &Intent{
|
||||
Activities: []string{"CloneRepoActivity", "SecurityScanActivity"},
|
||||
Parameters: map[string]interface{}{"repo": "https://github.com/test/repo"},
|
||||
IsCron: true,
|
||||
CronSchedule: "0 2 * * *",
|
||||
CronTimezone: "UTC",
|
||||
WorkflowName: "daily-scan",
|
||||
}
|
||||
|
||||
input := LLMRouterInput{
|
||||
Message: "Run security scan daily at 2 AM",
|
||||
}
|
||||
|
||||
cronSpec, err := router.buildCronSpec(intent, input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCronSpec failed: %v", err)
|
||||
}
|
||||
|
||||
if cronSpec.Type != "CronWorkflow" {
|
||||
t.Errorf("expected type 'CronWorkflow', got %s", cronSpec.Type)
|
||||
}
|
||||
if cronSpec.Schedule != "0 2 * * *" {
|
||||
t.Errorf("expected schedule '0 2 * * *', got %s", cronSpec.Schedule)
|
||||
}
|
||||
if cronSpec.Timezone != "UTC" {
|
||||
t.Errorf("expected timezone 'UTC', got %s", cronSpec.Timezone)
|
||||
}
|
||||
if !cronSpec.EnableHistory {
|
||||
t.Error("expected EnableHistory=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParameters(t *testing.T) {
|
||||
kb, err := LoadKnowledgeBaseFromDefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load knowledge base: %v", err)
|
||||
}
|
||||
|
||||
router := &LLMRouter{
|
||||
knowledgeBase: kb,
|
||||
}
|
||||
|
||||
// Test first activity (CloneRepoActivity) - should use input references
|
||||
cloneAct := kb.GetActivity("CloneRepoActivity")
|
||||
intent := &Intent{
|
||||
Activities: []string{"CloneRepoActivity", "AnalyzeCodeActivity"},
|
||||
Parameters: map[string]interface{}{"repo": "https://github.com/test/repo"},
|
||||
}
|
||||
|
||||
params := router.buildParameters(cloneAct, intent, 0)
|
||||
if params["repo"] != "https://github.com/test/repo" {
|
||||
t.Errorf("expected repo from parameters, got %v", params["repo"])
|
||||
}
|
||||
|
||||
// Test second activity (AnalyzeCodeActivity) - should reference previous output
|
||||
analyzeAct := kb.GetActivity("AnalyzeCodeActivity")
|
||||
params = router.buildParameters(analyzeAct, intent, 1)
|
||||
if params["path"] != "${CloneRepoActivity.output.path}" {
|
||||
t.Errorf("expected JSONPath reference to CloneRepoActivity.output.path, got %v", params["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRetryPolicy(t *testing.T) {
|
||||
kb, err := LoadKnowledgeBaseFromDefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load knowledge base: %v", err)
|
||||
}
|
||||
|
||||
router := &LLMRouter{
|
||||
knowledgeBase: kb,
|
||||
}
|
||||
|
||||
// Flaky activity with retry error handling
|
||||
analyzeAct := kb.GetActivity("AnalyzeCodeActivity")
|
||||
intent := &Intent{ErrorHandling: "retry"}
|
||||
policy := router.buildRetryPolicy(analyzeAct, intent)
|
||||
|
||||
if policy.MaxAttempts != 3 {
|
||||
t.Errorf("expected 3 max attempts for flaky activity, got %d", policy.MaxAttempts)
|
||||
}
|
||||
if policy.BackoffRate != 2.0 {
|
||||
t.Errorf("expected backoff rate 2.0, got %f", policy.BackoffRate)
|
||||
}
|
||||
|
||||
// Fail-fast error handling
|
||||
intent = &Intent{ErrorHandling: "fail-fast"}
|
||||
policy = router.buildRetryPolicy(analyzeAct, intent)
|
||||
|
||||
if policy.MaxAttempts != 1 {
|
||||
t.Errorf("expected 1 max attempt for fail-fast, got %d", policy.MaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentJSONMarshal(t *testing.T) {
|
||||
intent := &Intent{
|
||||
Activities: []string{"CloneRepoActivity"},
|
||||
Parameters: map[string]interface{}{"repo": "https://test"},
|
||||
IsCron: true,
|
||||
CronSchedule: "0 * * * *",
|
||||
CronTimezone: "UTC",
|
||||
WorkflowName: "test",
|
||||
ErrorHandling: "retry",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded Intent
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.CronSchedule != intent.CronSchedule {
|
||||
t.Errorf("expected schedule %s, got %s", intent.CronSchedule, decoded.CronSchedule)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user