- 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:
@@ -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`.
|
||||
@@ -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