feat: RoutingWorkflow + LLM Router + Memory Activity
ci / test (push) Successful in 2m12s

- 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:
Test
2026-09-02 19:21:53 -07:00
parent 5a465b145c
commit a0e64224a7
74 changed files with 3950 additions and 12421 deletions
-531
View File
@@ -1,531 +0,0 @@
# Poimen Memory Service Integration
Go client for Poimen Memory Service with **Temporal Activities**. Provides create, update, retrieve, and context operations for knowledge management with full workflow integration, retry logic, and observability.
## Overview
Memory service endpoints:
- **POST /memory/ingest** — Create knowledge records (L1/L2/reference)
- **POST /memory/query** — Search knowledge (hybrid semantic+lexical)
- **POST /memory/context** — Retrieve context (three-tier: signature → vector → reference)
- **GET /memory/vault** — Browse vault files
- **GET /health** — Health check
## Temporal Activities
All operations are **Temporal Activities** with:
- ✅ Automatic retries (3 attempts by default)
- ✅ Timeout handling (per operation)
- ✅ Heartbeat monitoring
- ✅ Logging + observability
- ✅ Workflow integration
### Activity List
| Activity | Purpose |
|----------|---------|
| `CreateKnowledgeActivity` | Create L1/L2/reference records |
| `UpdateKnowledgeActivity` | Update existing knowledge |
| `SearchKnowledgeActivity` | Search hybrid (semantic+lexical) |
| `GetContextActivity` | Retrieve three-tier context |
| `GetVaultActivity` | Browse vault files |
| `HealthCheckActivity` | Check service health |
| `LearnFromExecutionActivity` | Learn from task results |
| `DiagnoseIssueActivity` | Diagnose tool/task issues |
| `AnalyzeErrorActivity` | Analyze errors, find solutions |
| `DocumentDecisionActivity` | Record workflow decisions |
| `SearchAndApplyActivity` | Search and apply knowledge |
| `RefreshMemoryActivity` | Periodic memory refresh |
### Register Activities
In worker setup:
```go
service := memory.NewService(baseURL, token, project)
memory.RegisterMemoryActivities(w, service)
```
### Use in Workflows
```go
// Simple activity call
id, err := memory.ExecuteCreateKnowledge(
ctx,
&memory.KnowledgeRecord{
Level: "L1",
Content: "...",
},
nil, // Use default options
)
// Custom retry policy
options := &memory.ActivityOptions{
RetryAttempts: 5,
RetryBackoff: time.Second,
}
recommendations, err := memory.ExecuteDiagnoseIssue(ctx, "kubectl", "pod-crash", options)
```
## Installation
Import package:
```go
import "github.com/poimen/workflows/internal/memory"
```
## Workflow Integration
### Example 1: Learning Workflow
```go
// Learn from task execution
func LearningWorkflow(ctx workflow.Context, taskID string) (string, error) {
// Execute task (placeholder)
result := fmt.Sprintf("Task %s completed successfully", taskID)
// Learn from result
knowledgeID, err := memory.ExecuteLearnFromExecution(
ctx,
taskID,
result,
[]string{"success", taskID},
nil, // Default retry policy
)
return knowledgeID, err
}
```
### Example 2: Diagnostic Workflow
```go
// Diagnose issue using memory service
func DiagnosticWorkflow(ctx workflow.Context, tool, issue string) ([]string, error) {
recommendations, err := memory.ExecuteDiagnoseIssue(
ctx,
tool,
issue,
&memory.ActivityOptions{
RetryAttempts: 3,
RetryBackoff: time.Second,
},
)
return recommendations, err
}
```
### Example 3: Error Recovery
```go
// Analyze error and find recovery path
func ErrorRecoveryWorkflow(ctx workflow.Context, errorMsg string) ([]string, error) {
// Analyze error
records, err := memory.ExecuteAnalyzeError(ctx, errorMsg, nil)
if err != nil {
return nil, err
}
// Extract recovery steps
recovery := make([]string, 0)
for _, record := range records {
if record.Level == "L1" { // High confidence
recovery = append(recovery, record.Content)
}
}
return recovery, nil
}
```
### Example 4: Multi-Step Decision Workflow
```go
// Get context, make decision, document it
func ContextualDecisionWorkflow(ctx workflow.Context, tool, task, decision string) (string, error) {
// Get context (three-tier retrieval)
svcCtx, err := memory.ExecuteGetContext(ctx, tool, task, 8192, nil)
if err != nil {
return "", err
}
// Make decision based on context
reasoning := fmt.Sprintf("Based on %d lessons (tier %d)", len(svcCtx.Lessons), svcCtx.Tier)
// Document decision
docID, err := memory.ExecuteDocumentDecision(ctx, tool, decision, reasoning, nil)
return docID, err
}
```
## Usage
### Client (Low-Level)
```go
package main
import (
"context"
"fmt"
"log"
"github.com/poimen/workflows/internal/memory"
)
func main() {
// Create client
client := memory.NewClient(
"http://localhost:8080",
"your-jwt-token",
)
ctx := context.Background()
// Ingest knowledge
resp, err := client.Ingest(ctx, &memory.IngestRequest{
Project: "poimen",
Source: "workflow://task-123",
Kind: "L1",
Text: "Pod CrashLoopBackOff: check logs with kubectl logs",
Metadata: map[string]interface{}{
"topic": "kubernetes",
"task_id": "debug-pod",
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created: %s (SHA256: %s)\n", resp.ID, resp.SHA256)
// Search knowledge
query, err := client.Query(ctx, &memory.QueryRequest{
Project: "poimen",
Query: "fix pod crash loop",
Limit: 5,
Floor: 0.6, // minimum relevance
})
if err != nil {
log.Fatal(err)
}
for _, r := range query.Results {
fmt.Printf("%s (score: %.2f): %s\n", r.Level, r.Score, r.Text)
}
// Get context (three-tier retrieval)
ctxResp, err := client.Context(ctx, &memory.ContextRequest{
Project: "poimen",
Tool: "kubectl",
Task: "debug-pod",
SignatureSource: "error_log",
Budget: 8192,
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Context tier: %d\n", ctxResp.Tier)
for _, lesson := range ctxResp.Lessons {
fmt.Printf("- [Tier %d] %s: %.2f\n", lesson.Tier, lesson.Level, lesson.Score)
}
// Browse vault
vault, err := client.Vault(ctx, "poimen")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total records: %d\n", vault.TotalRecords)
for _, f := range vault.Files {
fmt.Printf("- %s (%s, %d records)\n", f.Path, f.Level, f.RecordCount)
}
}
```
### Service (High-Level)
```go
package main
import (
"context"
"log"
"github.com/poimen/workflows/internal/memory"
)
func main() {
// Create service
svc := memory.NewService(
"http://localhost:8080",
"your-jwt-token",
"poimen", // project
)
ctx := context.Background()
// Create knowledge
id, err := svc.CreateKnowledge(ctx, &memory.KnowledgeRecord{
Level: "L1",
Title: "Pod Debugging",
Content: "To debug CrashLoopBackOff: kubectl logs <pod>",
Source: "workflow://debug-task",
})
if err != nil {
log.Fatal(err)
}
log.Printf("Created knowledge: %s\n", id)
// Update knowledge (re-ingest with same ID)
id, err = svc.UpdateKnowledge(ctx, &memory.KnowledgeRecord{
ID: id,
Level: "L2",
Content: "Advanced debugging: check events, describe pod, check node status",
})
if err != nil {
log.Fatal(err)
}
log.Printf("Updated knowledge: %s\n", id)
// Retrieve knowledge
records, err := svc.RetrieveKnowledge(ctx, "kubernetes pod debugging", &memory.RetrievalOptions{
LevelFilter: []string{"L1", "L2"},
Limit: 10,
Floor: 0.7,
})
if err != nil {
log.Fatal(err)
}
for _, rec := range records {
log.Printf("- %s: %s\n", rec.ID, rec.Content)
}
// Retrieve context
svcCtx, err := svc.RetrieveContext(ctx, "kubectl", "debug-pod", 8192)
if err != nil {
log.Fatal(err)
}
log.Printf("Context tier: %d (%d lessons, %d skills)\n",
svcCtx.Tier, len(svcCtx.Lessons), len(svcCtx.Skills))
for _, skill := range svcCtx.Skills {
log.Printf(" - %s: %s\n", skill.Name, skill.Why)
}
// Get vault
files, err := svc.GetVault(ctx)
if err != nil {
log.Fatal(err)
}
log.Printf("Vault has %d files\n", len(files))
// Check health
if svc.IsHealthy(ctx) {
log.Println("Memory service is healthy")
}
}
```
## API Reference
### Client Methods
#### Ingest(ctx, req) → IngestResponse, error
Create knowledge record.
Request:
```go
&IngestRequest{
Project: "poimen",
Source: "workflow://task-id",
Kind: "L1", // L1|L2|reference
Text: "knowledge content",
Metadata: map[string]interface{}{...},
}
```
Response:
```go
{
ID: "chunk-abc123",
SHA256: "de12cd34ef56...",
QueueStatus: "pending", // Async processing
IdempotencyID: "sess-123:0",
}
```
#### Query(ctx, req) → QueryResponse, error
Search knowledge (hybrid semantic + lexical).
Request:
```go
&QueryRequest{
Project: "poimen",
Query: "fix kubernetes pod crash",
LevelFilter: []string{"L1", "L2"}, // Optional
Floor: 0.6, // Minimum relevance
Limit: 10,
Scope: "all", // learned|reference|all
}
```
Response:
```go
{
Query: "...",
Results: []QueryResult{
{
ID: "chunk-abc123",
Level: "L1",
Score: 0.992,
SemanticScore: 1.0,
LexicalScore: 0.98,
Text: "...",
Breadcrumb: "kubernetes.md > Troubleshooting",
Source: "transcript://session-123",
},
...
},
TotalHits: 127,
SearchTimeMS: 145,
}
```
#### Context(ctx, req) → ContextResponse, error
Retrieve context for tool/task (three-tier retrieval: signature → vector → reference).
Request:
```go
&ContextRequest{
Project: "poimen",
Tool: "kubectl",
Task: "debug-pod",
SignatureSource: "failure_log", // Where to find signature
Scope: "tool_context",
Budget: 8192, // Max response bytes
}
```
Response:
```go
{
Tier: 1, // Highest tier with results
Lessons: []ContextLesson{
{
Tier: 1,
Level: "L1",
Score: 1.0,
Text: "Pod in CrashLoopBackOff: check logs",
MatchedKind: "signature",
SeenCount: 23,
LastSeen: "2025-01-28T15:30:00Z",
},
...
},
Skills: []ContextSkill{
{
Name: "diagnose-pod-failure",
Why: "Tier-1 signature matched",
},
},
Budget: {
Requested: 8192,
Used: 4156,
Dropped: 0,
Degradation: nil,
},
}
```
#### Vault(ctx, project) → VaultResponse, error
Browse vault files.
Response:
```go
{
Project: "poimen",
Files: []VaultFile{
{
Path: "kubernetes/debugging.md",
Title: "Debugging",
Level: "L1",
UpdatedAt: "2025-01-28T10:00:00Z",
RecordCount: 23,
},
...
},
TotalRecords: 542,
}
```
#### Health(ctx) → bool, error
Check service health.
### Service Methods
Service provides higher-level operations:
- `CreateKnowledge(ctx, record)` → id, error
- `UpdateKnowledge(ctx, record)` → id, error
- `RetrieveKnowledge(ctx, query, opts)` → []KnowledgeRecord, error
- `RetrieveContext(ctx, tool, task, budget)` → *ServiceContext, error
- `GetVault(ctx)` → []VaultInfo, error
- `IsHealthy(ctx)` → bool
## Error Handling
```go
// All operations return (result, error)
resp, err := client.Ingest(ctx, req)
if err != nil {
// Possible errors:
// - Request marshal/network errors
// - 401 Unauthorized: Missing/invalid JWT
// - 403 Forbidden: Token lacks capability
// - 429 Too Many Requests: Rate limit exceeded
// - 409 Conflict: Duplicate (same idempotency key within 24h)
// - 503 Service Unavailable: Database unreachable
log.Fatalf("ingest failed: %v", err)
}
```
## Authentication
Pass JWT bearer token to NewClient/NewService:
```go
// Get token from Authentik
token := "eyJ0eXAiOiJKV1QiLCJhbGc..."
client := memory.NewClient(baseURL, token)
```
Token must have capability:
- `memory:read` — for Query, Context, Vault
- `memory:write` — for Ingest
## Rate Limits
Per JWT identity:
- Ingest: 100/hour
- Query: 1000/hour
- Context: 100/hour
Exceed limit → 429 Too Many Requests.
## Deployment
Memory service endpoints (k8s):
- Service: `memory-service.poimen.svc.cluster.local:8080`
- Ingress: `https://memory.riotpiao.com` (external)
Environment:
```go
baseURL := "http://memory-service.poimen.svc.cluster.local:8080"
token := os.Getenv("MEMORY_SERVICE_TOKEN")
svc := memory.NewService(baseURL, token, "poimen")
```
## Testing
Run tests:
```bash
go test ./internal/memory -v
```
Mock server example in `client_test.go` and `service_test.go`.
+69 -2
View File
@@ -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
}
}
}
+16
View File
@@ -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")
}
+110
View File
@@ -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
}
+456
View File
@@ -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)
}
})
}
}
+305
View File
@@ -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)
}
}