mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-20 06:35:48 +02:00
- Add exec_ts() and exec_ts_file() utility functions for inline and file-based TypeScript execution via Bun - Implement CDN/WAF detection system with is_cdn, is_cloud, is_waf boolean fields for assets - Enhance Python execution to prefer uv package manager with fallback to python3/python - Update roadmap with cloud integration step, clarify security warning, improve documentation
1225 lines
36 KiB
Go
1225 lines
36 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/j3ssie/osmedeus/v5/internal/config"
|
|
"github.com/j3ssie/osmedeus/v5/internal/core"
|
|
"github.com/j3ssie/osmedeus/v5/internal/functions"
|
|
"github.com/j3ssie/osmedeus/v5/internal/json"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"github.com/j3ssie/osmedeus/v5/internal/template"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AgentExecutor implements the agentic loop for type: agent steps.
|
|
// It queries the LLM, handles tool calls by executing them via the ToolExecutorRegistry,
|
|
// feeds results back, and repeats until the LLM responds without tool calls or
|
|
// the max_iterations limit is reached.
|
|
type AgentExecutor struct {
|
|
templateEngine template.TemplateEngine
|
|
functionRegistry *functions.Registry
|
|
config *config.Config
|
|
silent bool
|
|
currentDepth int // 0 = top-level
|
|
maxDepth int // default 3
|
|
}
|
|
|
|
// NewAgentExecutor creates a new agent executor
|
|
func NewAgentExecutor(engine template.TemplateEngine, funcRegistry *functions.Registry) *AgentExecutor {
|
|
return &AgentExecutor{
|
|
templateEngine: engine,
|
|
functionRegistry: funcRegistry,
|
|
}
|
|
}
|
|
|
|
// Name returns the executor name
|
|
func (e *AgentExecutor) Name() string {
|
|
return "agent"
|
|
}
|
|
|
|
// StepTypes returns the step types this executor handles
|
|
func (e *AgentExecutor) StepTypes() []core.StepType {
|
|
return []core.StepType{core.StepTypeAgent}
|
|
}
|
|
|
|
// SetConfig sets the application config
|
|
func (e *AgentExecutor) SetConfig(cfg *config.Config) {
|
|
e.config = cfg
|
|
}
|
|
|
|
// SetSilent enables or disables silent mode
|
|
func (e *AgentExecutor) SetSilent(s bool) {
|
|
e.silent = s
|
|
}
|
|
|
|
// SetDepthContext sets the current nesting depth and maximum allowed depth.
|
|
func (e *AgentExecutor) SetDepthContext(depth, maxDepth int) {
|
|
e.currentDepth = depth
|
|
e.maxDepth = maxDepth
|
|
}
|
|
|
|
// agentState tracks the agent's runtime state during execution
|
|
type agentState struct {
|
|
messages []ChatMessage
|
|
totalTokens int
|
|
promptTokens int
|
|
completionTokens int
|
|
iteration int
|
|
toolResults []map[string]interface{}
|
|
finalContent string
|
|
planContent string // populated by planning stage
|
|
goalResults []map[string]interface{} // results from each goal in multi-goal mode
|
|
toolRegistry *ToolExecutorRegistry // pluggable tool dispatch
|
|
tokenMu sync.Mutex // protects token fields for concurrent sub-agent merging
|
|
}
|
|
|
|
// MergeTokens safely adds child agent token counts into this state.
|
|
// Thread-safe for concurrent sub-agent spawning.
|
|
func (s *agentState) MergeTokens(total, prompt, completion int) {
|
|
s.tokenMu.Lock()
|
|
defer s.tokenMu.Unlock()
|
|
s.totalTokens += total
|
|
s.promptTokens += prompt
|
|
s.completionTokens += completion
|
|
}
|
|
|
|
// Execute runs the agent loop
|
|
func (e *AgentExecutor) Execute(ctx context.Context, step *core.Step, execCtx *core.ExecutionContext) (*core.StepResult, error) {
|
|
log := logger.Get()
|
|
result := &core.StepResult{
|
|
StepName: step.Name,
|
|
Status: core.StepStatusRunning,
|
|
StartTime: time.Now(),
|
|
Exports: make(map[string]interface{}),
|
|
}
|
|
|
|
// Validate config
|
|
if e.config == nil {
|
|
return e.fail(result, fmt.Errorf("agent executor config not set"))
|
|
}
|
|
|
|
// Validate required fields: need query OR queries (not both)
|
|
if step.Query == "" && len(step.Queries) == 0 {
|
|
return e.fail(result, fmt.Errorf("agent step '%s' requires 'query' or 'queries' field", step.Name))
|
|
}
|
|
if step.Query != "" && len(step.Queries) > 0 {
|
|
return e.fail(result, fmt.Errorf("agent step '%s' cannot have both 'query' and 'queries'", step.Name))
|
|
}
|
|
if step.MaxIterations <= 0 {
|
|
return e.fail(result, fmt.Errorf("agent step '%s' requires 'max_iterations' > 0", step.Name))
|
|
}
|
|
if len(step.AgentTools) == 0 {
|
|
return e.fail(result, fmt.Errorf("agent step '%s' requires 'agent_tools' field", step.Name))
|
|
}
|
|
|
|
// Parse output schema if specified
|
|
var outputSchema *core.LLMResponseFormat
|
|
if step.OutputSchema != "" {
|
|
var err error
|
|
outputSchema, err = core.ParseOutputSchema(step.OutputSchema)
|
|
if err != nil {
|
|
return e.fail(result, fmt.Errorf("agent step '%s': %w", step.Name, err))
|
|
}
|
|
}
|
|
|
|
// Resolve agent tools to OpenAI-compatible schemas (with spawn_agent if sub-agents present)
|
|
tools, err := core.ResolveAgentToolsWithSubAgents(step.AgentTools, step.SubAgents)
|
|
if err != nil {
|
|
return e.fail(result, fmt.Errorf("agent step '%s': %w", step.Name, err))
|
|
}
|
|
|
|
// Get merged LLM config
|
|
llmConfig := e.getMergedConfig(step)
|
|
|
|
// Compute effective max depth
|
|
effectiveMaxDepth := step.MaxAgentDepth
|
|
if effectiveMaxDepth <= 0 {
|
|
effectiveMaxDepth = core.DefaultMaxAgentDepth
|
|
}
|
|
// If spawned as child, inherit parent's max depth
|
|
if e.maxDepth > 0 {
|
|
effectiveMaxDepth = e.maxDepth
|
|
}
|
|
|
|
// Initialize state before building tool registry (needed as parentState arg)
|
|
state := &agentState{}
|
|
|
|
// Build ToolExecutorRegistry with sub-agent support if needed
|
|
var toolRegistry *ToolExecutorRegistry
|
|
if len(step.SubAgents) > 0 {
|
|
toolRegistry = BuildToolRegistryWithSubAgents(
|
|
step.AgentTools, e.functionRegistry,
|
|
e.templateEngine, e.config, e.silent,
|
|
e.currentDepth, effectiveMaxDepth,
|
|
state, step.SubAgents,
|
|
)
|
|
} else {
|
|
toolRegistry = BuildToolRegistry(step.AgentTools, e.functionRegistry)
|
|
}
|
|
state.toolRegistry = toolRegistry
|
|
|
|
// Load resumed conversation if specified
|
|
if step.Memory != nil && step.Memory.ResumePath != "" {
|
|
if err := e.loadConversation(state, step.Memory.ResumePath); err != nil {
|
|
log.Warn("Failed to load conversation for resume, starting fresh",
|
|
zap.String("path", step.Memory.ResumePath),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Determine queries to execute (Group 3: multi-goal)
|
|
queries := []string{step.Query}
|
|
if len(step.Queries) > 0 {
|
|
queries = step.Queries
|
|
}
|
|
|
|
// Run planning stage if configured (Group 2)
|
|
if step.PlanPrompt != "" {
|
|
planContent, err := e.executePlanningStage(ctx, state, step, llmConfig)
|
|
if err != nil {
|
|
log.Warn("Planning stage failed, continuing without plan",
|
|
zap.String("step", step.Name),
|
|
zap.Error(err),
|
|
)
|
|
} else {
|
|
state.planContent = planContent
|
|
}
|
|
}
|
|
|
|
// Execute each query (single or multi-goal)
|
|
for goalIdx, query := range queries {
|
|
if query == "" {
|
|
continue
|
|
}
|
|
|
|
// Build initial messages for this goal
|
|
e.initMessages(state, query, step, llmConfig, goalIdx)
|
|
|
|
log.Debug("Starting agent loop",
|
|
zap.String("step", step.Name),
|
|
zap.Int("goal", goalIdx+1),
|
|
zap.Int("total_goals", len(queries)),
|
|
zap.Int("max_iterations", step.MaxIterations),
|
|
zap.Int("tools", len(tools)),
|
|
)
|
|
|
|
// Main agent loop
|
|
for state.iteration = 1; state.iteration <= step.MaxIterations; state.iteration++ {
|
|
log.Debug("Agent iteration",
|
|
zap.String("step", step.Name),
|
|
zap.Int("iteration", state.iteration),
|
|
zap.Int("messages", len(state.messages)),
|
|
)
|
|
|
|
// Build LLM request, potentially with structured output on final iteration
|
|
var responseFormat *core.LLMResponseFormat
|
|
if outputSchema != nil && state.iteration == step.MaxIterations {
|
|
responseFormat = outputSchema
|
|
}
|
|
|
|
// Call LLM with optional model fallback (Group 5)
|
|
response, err := e.callLLMWithFallback(ctx, state, tools, llmConfig, step.Models, responseFormat)
|
|
if err != nil {
|
|
return e.fail(result, fmt.Errorf("agent step '%s' iteration %d: %w", step.Name, state.iteration, err))
|
|
}
|
|
|
|
if response == nil || len(response.Choices) == 0 {
|
|
return e.fail(result, fmt.Errorf("agent step '%s': empty response from LLM", step.Name))
|
|
}
|
|
|
|
// Track tokens
|
|
state.totalTokens += response.Usage.TotalTokens
|
|
state.promptTokens += response.Usage.PromptTokens
|
|
state.completionTokens += response.Usage.CompletionTokens
|
|
|
|
choice := response.Choices[0]
|
|
|
|
// Append assistant message to conversation
|
|
state.messages = append(state.messages, choice.Message)
|
|
|
|
// Extract content
|
|
if content, ok := choice.Message.Content.(string); ok {
|
|
state.finalContent = content
|
|
}
|
|
|
|
// Check if we're done (no tool calls)
|
|
if len(choice.Message.ToolCalls) == 0 {
|
|
// If we have OutputSchema and this isn't the max iteration,
|
|
// try to enforce structured output on the next call
|
|
if outputSchema != nil && state.iteration < step.MaxIterations {
|
|
log.Debug("Agent completed (no tool calls), structured output available",
|
|
zap.String("step", step.Name),
|
|
zap.Int("iterations", state.iteration),
|
|
)
|
|
} else {
|
|
log.Debug("Agent completed (no tool calls)",
|
|
zap.String("step", step.Name),
|
|
zap.Int("iterations", state.iteration),
|
|
)
|
|
}
|
|
break
|
|
}
|
|
|
|
// Execute tool calls with tracing hooks (Group 7)
|
|
toolMessages, err := e.executeToolCalls(ctx, choice.Message.ToolCalls, state, step, execCtx)
|
|
if err != nil {
|
|
return e.fail(result, fmt.Errorf("agent step '%s' iteration %d tool execution: %w", step.Name, state.iteration, err))
|
|
}
|
|
|
|
// Append tool results to conversation
|
|
state.messages = append(state.messages, toolMessages...)
|
|
|
|
// Evaluate stop condition if defined
|
|
if step.StopCondition != "" {
|
|
vars := execCtx.GetVariables()
|
|
vars["agent_content"] = state.finalContent
|
|
vars["iteration"] = state.iteration
|
|
shouldStop, err := e.functionRegistry.EvaluateCondition(step.StopCondition, vars)
|
|
if err != nil {
|
|
log.Warn("Stop condition evaluation failed",
|
|
zap.String("step", step.Name),
|
|
zap.Error(err),
|
|
)
|
|
} else if shouldStop {
|
|
log.Debug("Agent stopped by stop_condition",
|
|
zap.String("step", step.Name),
|
|
zap.Int("iteration", state.iteration),
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Apply sliding window if configured (with optional compression — Group 4)
|
|
if step.Memory != nil && step.Memory.MaxMessages > 0 {
|
|
if step.Memory.SummarizeOnTruncate {
|
|
e.applyMessageWindowWithSummary(ctx, state, step.Memory.MaxMessages, llmConfig)
|
|
} else {
|
|
e.applyMessageWindow(state, step.Memory.MaxMessages)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Record goal result (Group 3)
|
|
goalResult := map[string]interface{}{
|
|
"query": query,
|
|
"content": state.finalContent,
|
|
}
|
|
state.goalResults = append(state.goalResults, goalResult)
|
|
}
|
|
|
|
// If OutputSchema is set and we haven't gotten structured output yet,
|
|
// make a final structured output request (Group 6)
|
|
if outputSchema != nil {
|
|
structuredContent := e.requestStructuredOutput(ctx, state, outputSchema, llmConfig)
|
|
if structuredContent != "" {
|
|
state.finalContent = structuredContent
|
|
}
|
|
}
|
|
|
|
// Print final output (skip if streaming — tokens were already printed in real-time)
|
|
if !e.silent && !llmConfig.Stream && state.finalContent != "" {
|
|
printLLMOutput(state.finalContent)
|
|
}
|
|
|
|
// Persist conversation if configured
|
|
if step.Memory != nil && step.Memory.PersistPath != "" {
|
|
if err := e.persistConversation(state, step.Memory.PersistPath); err != nil {
|
|
log.Warn("Failed to persist agent conversation",
|
|
zap.String("path", step.Memory.PersistPath),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Set exports
|
|
historyJSON, err := json.Marshal(state.messages)
|
|
if err != nil {
|
|
log.Warn("Failed to marshal agent history", zap.Error(err))
|
|
historyJSON = []byte("[]")
|
|
}
|
|
toolResultsJSON, err := json.Marshal(state.toolResults)
|
|
if err != nil {
|
|
log.Warn("Failed to marshal agent tool results", zap.Error(err))
|
|
toolResultsJSON = []byte("[]")
|
|
}
|
|
|
|
result.Exports["agent_content"] = state.finalContent
|
|
result.Exports["agent_history"] = string(historyJSON)
|
|
// Cap iteration count: the for-loop post-increments past MaxIterations on natural exit
|
|
iterations := state.iteration
|
|
if iterations > step.MaxIterations {
|
|
iterations = step.MaxIterations
|
|
}
|
|
result.Exports["agent_iterations"] = iterations
|
|
result.Exports["agent_total_tokens"] = state.totalTokens
|
|
result.Exports["agent_prompt_tokens"] = state.promptTokens
|
|
result.Exports["agent_completion_tokens"] = state.completionTokens
|
|
result.Exports["agent_tool_results"] = string(toolResultsJSON)
|
|
|
|
// Planning stage export (Group 2)
|
|
if state.planContent != "" {
|
|
result.Exports["agent_plan"] = state.planContent
|
|
}
|
|
|
|
// Multi-goal results export (Group 3)
|
|
if len(state.goalResults) > 1 {
|
|
goalResultsJSON, err := json.Marshal(state.goalResults)
|
|
if err != nil {
|
|
log.Warn("Failed to marshal goal results", zap.Error(err))
|
|
goalResultsJSON = []byte("[]")
|
|
}
|
|
result.Exports["agent_goal_results"] = string(goalResultsJSON)
|
|
}
|
|
|
|
result.Output = state.finalContent
|
|
result.Status = core.StepStatusSuccess
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// executePlanningStage runs the planning phase before the main agent loop (Group 2)
|
|
func (e *AgentExecutor) executePlanningStage(
|
|
ctx context.Context,
|
|
state *agentState,
|
|
step *core.Step,
|
|
llmConfig *MergedLLMConfig,
|
|
) (string, error) {
|
|
log := logger.Get()
|
|
log.Debug("Executing planning stage",
|
|
zap.String("step", step.Name),
|
|
)
|
|
|
|
// Build planning messages
|
|
var planMessages []ChatMessage
|
|
|
|
// System prompt if available
|
|
systemPrompt := step.SystemPrompt
|
|
if systemPrompt == "" && llmConfig.SystemPrompt != "" {
|
|
systemPrompt = llmConfig.SystemPrompt
|
|
}
|
|
if systemPrompt != "" {
|
|
planMessages = append(planMessages, ChatMessage{
|
|
Role: string(core.LLMRoleSystem),
|
|
Content: systemPrompt,
|
|
})
|
|
}
|
|
|
|
// Plan prompt as user message
|
|
planMessages = append(planMessages, ChatMessage{
|
|
Role: string(core.LLMRoleUser),
|
|
Content: step.PlanPrompt,
|
|
})
|
|
|
|
// Build planning request (no tools — just text generation)
|
|
planConfig := *llmConfig
|
|
if step.PlanMaxTokens != nil {
|
|
planConfig.MaxTokens = *step.PlanMaxTokens
|
|
}
|
|
|
|
planState := &agentState{messages: planMessages}
|
|
response, err := e.callLLM(ctx, planState, nil, &planConfig)
|
|
if err != nil {
|
|
return "", fmt.Errorf("planning request failed: %w", err)
|
|
}
|
|
|
|
if response == nil || len(response.Choices) == 0 {
|
|
return "", fmt.Errorf("empty response from planning request")
|
|
}
|
|
|
|
// Track tokens from planning
|
|
state.totalTokens += response.Usage.TotalTokens
|
|
state.promptTokens += response.Usage.PromptTokens
|
|
state.completionTokens += response.Usage.CompletionTokens
|
|
|
|
planContent := ""
|
|
if content, ok := response.Choices[0].Message.Content.(string); ok {
|
|
planContent = content
|
|
}
|
|
|
|
log.Debug("Planning stage complete",
|
|
zap.String("step", step.Name),
|
|
zap.Int("plan_length", len(planContent)),
|
|
)
|
|
|
|
return planContent, nil
|
|
}
|
|
|
|
// initMessages builds the initial conversation messages for a goal
|
|
func (e *AgentExecutor) initMessages(state *agentState, query string, step *core.Step, llmConfig *MergedLLMConfig, goalIdx int) {
|
|
// For the first goal, build from scratch or resume
|
|
if goalIdx == 0 {
|
|
// If we loaded resumed messages, just append the new query
|
|
if len(state.messages) > 0 {
|
|
state.messages = append(state.messages, ChatMessage{
|
|
Role: string(core.LLMRoleUser),
|
|
Content: query,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Start fresh conversation
|
|
systemPrompt := step.SystemPrompt
|
|
if systemPrompt == "" && llmConfig.SystemPrompt != "" {
|
|
systemPrompt = llmConfig.SystemPrompt
|
|
}
|
|
if systemPrompt != "" {
|
|
state.messages = append(state.messages, ChatMessage{
|
|
Role: string(core.LLMRoleSystem),
|
|
Content: systemPrompt,
|
|
})
|
|
}
|
|
|
|
// Prepend plan if available (Group 2)
|
|
if state.planContent != "" {
|
|
state.messages = append(state.messages, ChatMessage{
|
|
Role: string(core.LLMRoleAssistant),
|
|
Content: "Here is my plan:\n\n" + state.planContent,
|
|
})
|
|
}
|
|
|
|
// User query
|
|
state.messages = append(state.messages, ChatMessage{
|
|
Role: string(core.LLMRoleUser),
|
|
Content: query,
|
|
})
|
|
return
|
|
}
|
|
|
|
// For subsequent goals in multi-goal mode, append new user message
|
|
state.messages = append(state.messages, ChatMessage{
|
|
Role: string(core.LLMRoleUser),
|
|
Content: query,
|
|
})
|
|
}
|
|
|
|
// callLLMWithFallback calls the LLM with optional per-agent model fallback (Group 5)
|
|
func (e *AgentExecutor) callLLMWithFallback(
|
|
ctx context.Context,
|
|
state *agentState,
|
|
tools []core.LLMTool,
|
|
llmConfig *MergedLLMConfig,
|
|
models []string,
|
|
responseFormat *core.LLMResponseFormat,
|
|
) (*ChatCompletionResponse, error) {
|
|
// If step specifies preferred models, try each in order
|
|
if len(models) > 0 {
|
|
var lastErr error
|
|
for _, model := range models {
|
|
modelConfig := *llmConfig
|
|
modelConfig.Model = model
|
|
if responseFormat != nil {
|
|
modelConfig.ResponseFormat = responseFormat
|
|
}
|
|
response, err := e.callLLM(ctx, state, tools, &modelConfig)
|
|
if err == nil && (response == nil || response.Error == nil) {
|
|
return response, nil
|
|
}
|
|
lastErr = err
|
|
logger.Get().Debug("Model fallback: trying next model",
|
|
zap.String("failed_model", model),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
// Fall through to default provider rotation
|
|
logger.Get().Warn("All specified models failed, falling back to default",
|
|
zap.Error(lastErr),
|
|
)
|
|
}
|
|
|
|
// Default path: use standard provider rotation
|
|
if responseFormat != nil {
|
|
configWithFormat := *llmConfig
|
|
configWithFormat.ResponseFormat = responseFormat
|
|
return e.callLLM(ctx, state, tools, &configWithFormat)
|
|
}
|
|
return e.callLLM(ctx, state, tools, llmConfig)
|
|
}
|
|
|
|
// callLLM sends the current conversation to the LLM and returns the response
|
|
func (e *AgentExecutor) callLLM(
|
|
ctx context.Context,
|
|
state *agentState,
|
|
tools []core.LLMTool,
|
|
llmConfig *MergedLLMConfig,
|
|
) (*ChatCompletionResponse, error) {
|
|
log := logger.Get()
|
|
|
|
request := &ChatCompletionRequest{
|
|
Model: llmConfig.Model,
|
|
Messages: state.messages,
|
|
MaxTokens: llmConfig.MaxTokens,
|
|
Temperature: llmConfig.Temperature,
|
|
TopP: llmConfig.TopP,
|
|
TopK: llmConfig.TopK,
|
|
Tools: tools,
|
|
Stream: llmConfig.Stream,
|
|
ResponseFormat: llmConfig.ResponseFormat,
|
|
}
|
|
|
|
// Provider rotation with retries
|
|
var response *ChatCompletionResponse
|
|
var lastErr error
|
|
|
|
maxRetries := llmConfig.MaxRetries
|
|
if maxRetries <= 0 {
|
|
maxRetries = 3
|
|
}
|
|
providerCount := e.config.LLM.GetProviderCount()
|
|
if providerCount == 0 {
|
|
return nil, fmt.Errorf("no LLM providers configured")
|
|
}
|
|
|
|
totalAttempts := maxRetries * providerCount
|
|
|
|
for attempt := 0; attempt < totalAttempts; attempt++ {
|
|
provider := e.config.LLM.GetCurrentProvider()
|
|
if provider == nil {
|
|
lastErr = fmt.Errorf("no LLM providers available")
|
|
break
|
|
}
|
|
|
|
if llmConfig.Model == "" {
|
|
request.Model = provider.Model
|
|
}
|
|
|
|
log.Debug("Agent LLM request",
|
|
zap.String("provider", provider.Provider),
|
|
zap.String("model", request.Model),
|
|
zap.Int("attempt", attempt+1),
|
|
)
|
|
|
|
response, lastErr = e.sendChatRequest(ctx, provider, request, llmConfig)
|
|
|
|
if lastErr == nil && response.Error == nil {
|
|
break
|
|
}
|
|
|
|
if isProviderError(lastErr) || isRateLimitError(response) {
|
|
e.config.LLM.RotateProvider()
|
|
}
|
|
|
|
if attempt < totalAttempts-1 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(time.Duration(attempt+1) * 500 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
|
|
if response != nil && response.Error != nil {
|
|
return nil, fmt.Errorf("LLM API error: %s (%s)", response.Error.Message, response.Error.Type)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// sendChatRequest sends an HTTP request to the LLM provider (delegates to LLMExecutor's logic)
|
|
func (e *AgentExecutor) sendChatRequest(
|
|
ctx context.Context,
|
|
provider *config.LLMProvider,
|
|
request *ChatCompletionRequest,
|
|
llmConfig *MergedLLMConfig,
|
|
) (*ChatCompletionResponse, error) {
|
|
llmExec := &LLMExecutor{config: e.config}
|
|
return llmExec.sendChatRequest(ctx, provider, request, llmConfig)
|
|
}
|
|
|
|
// executeToolCalls executes tool calls from the LLM response, with tracing hooks (Group 7)
|
|
func (e *AgentExecutor) executeToolCalls(
|
|
ctx context.Context,
|
|
toolCalls []core.LLMToolCall,
|
|
state *agentState,
|
|
step *core.Step,
|
|
execCtx *core.ExecutionContext,
|
|
) ([]ChatMessage, error) {
|
|
log := logger.Get()
|
|
|
|
parallelToolCalls := true
|
|
if step.ParallelToolCalls != nil {
|
|
parallelToolCalls = *step.ParallelToolCalls
|
|
}
|
|
|
|
if parallelToolCalls && len(toolCalls) > 1 {
|
|
return e.executeToolCallsParallel(ctx, toolCalls, state, step, execCtx, log)
|
|
}
|
|
|
|
return e.executeToolCallsSequential(ctx, toolCalls, state, step, execCtx, log)
|
|
}
|
|
|
|
// executeToolCallsSequential executes tool calls one at a time
|
|
func (e *AgentExecutor) executeToolCallsSequential(
|
|
ctx context.Context,
|
|
toolCalls []core.LLMToolCall,
|
|
state *agentState,
|
|
step *core.Step,
|
|
execCtx *core.ExecutionContext,
|
|
log *zap.Logger,
|
|
) ([]ChatMessage, error) {
|
|
messages := make([]ChatMessage, 0, len(toolCalls))
|
|
|
|
for _, tc := range toolCalls {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Execute on_tool_start hook (Group 7)
|
|
e.executeToolHook(step.OnToolStart, tc.Function.Name, tc.Function.Arguments, "", 0, state.iteration, nil, execCtx)
|
|
|
|
startTime := time.Now()
|
|
result, err := e.executeSingleToolCall(ctx, tc, state, execCtx, log)
|
|
duration := time.Since(startTime)
|
|
if err != nil {
|
|
result = fmt.Sprintf("Error executing tool '%s': %s", tc.Function.Name, err.Error())
|
|
}
|
|
|
|
// Execute on_tool_end hook (Group 7)
|
|
e.executeToolHook(step.OnToolEnd, tc.Function.Name, tc.Function.Arguments, result, duration.Milliseconds(), state.iteration, err, execCtx)
|
|
|
|
log.Debug("Tool call completed",
|
|
zap.String("tool", tc.Function.Name),
|
|
zap.String("call_id", tc.ID),
|
|
zap.Duration("duration", duration),
|
|
)
|
|
|
|
state.toolResults = append(state.toolResults, map[string]interface{}{
|
|
"tool_call_id": tc.ID,
|
|
"tool_name": tc.Function.Name,
|
|
"result": result,
|
|
})
|
|
|
|
messages = append(messages, ChatMessage{
|
|
Role: string(core.LLMRoleTool),
|
|
Content: result,
|
|
ToolCallID: tc.ID,
|
|
})
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// executeToolCallsParallel executes tool calls concurrently
|
|
func (e *AgentExecutor) executeToolCallsParallel(
|
|
ctx context.Context,
|
|
toolCalls []core.LLMToolCall,
|
|
state *agentState,
|
|
step *core.Step,
|
|
execCtx *core.ExecutionContext,
|
|
log *zap.Logger,
|
|
) ([]ChatMessage, error) {
|
|
type toolResult struct {
|
|
index int
|
|
message ChatMessage
|
|
}
|
|
|
|
results := make(chan toolResult, len(toolCalls))
|
|
var wg sync.WaitGroup
|
|
|
|
for i, tc := range toolCalls {
|
|
wg.Add(1)
|
|
go func(idx int, call core.LLMToolCall) {
|
|
defer wg.Done()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Execute on_tool_start hook (Group 7)
|
|
e.executeToolHook(step.OnToolStart, call.Function.Name, call.Function.Arguments, "", 0, state.iteration, nil, execCtx)
|
|
|
|
startTime := time.Now()
|
|
result, err := e.executeSingleToolCall(ctx, call, state, execCtx, log)
|
|
duration := time.Since(startTime)
|
|
if err != nil {
|
|
result = fmt.Sprintf("Error executing tool '%s': %s", call.Function.Name, err.Error())
|
|
}
|
|
|
|
// Execute on_tool_end hook (Group 7)
|
|
e.executeToolHook(step.OnToolEnd, call.Function.Name, call.Function.Arguments, result, duration.Milliseconds(), state.iteration, err, execCtx)
|
|
|
|
log.Debug("Tool call completed",
|
|
zap.String("tool", call.Function.Name),
|
|
zap.String("call_id", call.ID),
|
|
zap.Duration("duration", duration),
|
|
)
|
|
|
|
results <- toolResult{
|
|
index: idx,
|
|
message: ChatMessage{
|
|
Role: string(core.LLMRoleTool),
|
|
Content: result,
|
|
ToolCallID: call.ID,
|
|
},
|
|
}
|
|
}(i, tc)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
// Collect and order results, populate toolResults
|
|
ordered := make([]ChatMessage, len(toolCalls))
|
|
for r := range results {
|
|
ordered[r.index] = r.message
|
|
}
|
|
for i, msg := range ordered {
|
|
state.toolResults = append(state.toolResults, map[string]interface{}{
|
|
"tool_call_id": toolCalls[i].ID,
|
|
"tool_name": toolCalls[i].Function.Name,
|
|
"result": msg.Content,
|
|
})
|
|
}
|
|
|
|
return ordered, nil
|
|
}
|
|
|
|
// executeSingleToolCall executes a single tool call via the ToolExecutorRegistry
|
|
func (e *AgentExecutor) executeSingleToolCall(
|
|
ctx context.Context,
|
|
tc core.LLMToolCall,
|
|
state *agentState,
|
|
execCtx *core.ExecutionContext,
|
|
log *zap.Logger,
|
|
) (string, error) {
|
|
// Use ToolExecutorRegistry if available (Group 1)
|
|
if state.toolRegistry != nil {
|
|
return executeToolCallViaRegistry(ctx, tc, state.toolRegistry, execCtx, log)
|
|
}
|
|
|
|
// Fallback to legacy dispatch (should not happen in normal flow)
|
|
return e.executeSingleToolCallLegacy(tc, execCtx, log)
|
|
}
|
|
|
|
// executeSingleToolCallLegacy is the legacy dispatch path (kept for safety)
|
|
func (e *AgentExecutor) executeSingleToolCallLegacy(
|
|
tc core.LLMToolCall,
|
|
execCtx *core.ExecutionContext,
|
|
log *zap.Logger,
|
|
) (string, error) {
|
|
funcName := tc.Function.Name
|
|
argsJSON := tc.Function.Arguments
|
|
|
|
log.Debug("Executing tool call (legacy)",
|
|
zap.String("tool", funcName),
|
|
zap.String("args", argsJSON),
|
|
)
|
|
|
|
var args map[string]interface{}
|
|
if argsJSON != "" {
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("failed to parse tool arguments for %s: %w", funcName, err)
|
|
}
|
|
}
|
|
if args == nil {
|
|
args = make(map[string]interface{})
|
|
}
|
|
|
|
return e.executePresetTool(funcName, args, execCtx)
|
|
}
|
|
|
|
// executePresetTool runs a preset tool by building a function call expression
|
|
func (e *AgentExecutor) executePresetTool(funcName string, args map[string]interface{}, execCtx *core.ExecutionContext) (string, error) {
|
|
expr := buildPresetCallExpr(funcName, args)
|
|
vars := execCtx.GetVariables()
|
|
result, err := e.functionRegistry.Execute(expr, vars)
|
|
if err != nil {
|
|
return "", fmt.Errorf("preset tool '%s' failed: %w", funcName, err)
|
|
}
|
|
return formatToolResult(result), nil
|
|
}
|
|
|
|
// executeToolHook evaluates a JS hook expression with tool call context (Group 7)
|
|
func (e *AgentExecutor) executeToolHook(
|
|
hook string,
|
|
toolName string,
|
|
toolArgs string,
|
|
result string,
|
|
durationMs int64,
|
|
iteration int,
|
|
toolErr error,
|
|
execCtx *core.ExecutionContext,
|
|
) {
|
|
if hook == "" || e.functionRegistry == nil {
|
|
return
|
|
}
|
|
|
|
log := logger.Get()
|
|
vars := execCtx.GetVariables()
|
|
vars["tool_name"] = toolName
|
|
vars["tool_args"] = toolArgs
|
|
vars["result"] = result
|
|
vars["duration"] = durationMs
|
|
vars["iteration"] = iteration
|
|
if toolErr != nil {
|
|
vars["error"] = toolErr.Error()
|
|
} else {
|
|
vars["error"] = ""
|
|
}
|
|
|
|
if _, err := e.functionRegistry.Execute(hook, vars); err != nil {
|
|
log.Warn("Tool hook execution failed (non-blocking)",
|
|
zap.String("hook", hook),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// requestStructuredOutput makes a final LLM call to enforce structured output (Group 6)
|
|
func (e *AgentExecutor) requestStructuredOutput(
|
|
ctx context.Context,
|
|
state *agentState,
|
|
schema *core.LLMResponseFormat,
|
|
llmConfig *MergedLLMConfig,
|
|
) string {
|
|
log := logger.Get()
|
|
|
|
// Only request if we have a schema and the last response might not be structured
|
|
if schema == nil {
|
|
return ""
|
|
}
|
|
|
|
// Check if current content is already valid JSON matching the schema
|
|
var check interface{}
|
|
if json.Unmarshal([]byte(state.finalContent), &check) == nil {
|
|
// Already valid JSON, likely structured
|
|
return ""
|
|
}
|
|
|
|
// Add instruction to produce structured output
|
|
state.messages = append(state.messages, ChatMessage{
|
|
Role: string(core.LLMRoleUser),
|
|
Content: "Please provide your final answer in the structured JSON format specified.",
|
|
})
|
|
|
|
configWithSchema := *llmConfig
|
|
configWithSchema.ResponseFormat = schema
|
|
|
|
response, err := e.callLLM(ctx, state, nil, &configWithSchema)
|
|
if err != nil {
|
|
log.Warn("Structured output request failed",
|
|
zap.Error(err),
|
|
)
|
|
return ""
|
|
}
|
|
|
|
if response == nil || len(response.Choices) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Track tokens
|
|
state.totalTokens += response.Usage.TotalTokens
|
|
state.promptTokens += response.Usage.PromptTokens
|
|
state.completionTokens += response.Usage.CompletionTokens
|
|
|
|
if content, ok := response.Choices[0].Message.Content.(string); ok {
|
|
return content
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildPresetCallExpr builds a JS function call expression from a preset tool name and arguments
|
|
func buildPresetCallExpr(funcName string, args map[string]interface{}) string {
|
|
switch funcName {
|
|
case "bash":
|
|
return fmt.Sprintf("bash(%s)", jsQuote(getStringArg(args, "command")))
|
|
case "read_file":
|
|
return fmt.Sprintf("read_file(%s)", jsQuote(getStringArg(args, "path")))
|
|
case "read_lines":
|
|
return fmt.Sprintf("read_lines(%s)", jsQuote(getStringArg(args, "path")))
|
|
case "file_exists":
|
|
return fmt.Sprintf("file_exists(%s)", jsQuote(getStringArg(args, "path")))
|
|
case "file_length":
|
|
return fmt.Sprintf("file_length(%s)", jsQuote(getStringArg(args, "path")))
|
|
case "append_file":
|
|
return fmt.Sprintf("append_file(%s, %s)", jsQuote(getStringArg(args, "dest")), jsQuote(getStringArg(args, "content")))
|
|
case "save_content":
|
|
return fmt.Sprintf("save_content(%s, %s)", jsQuote(getStringArg(args, "content")), jsQuote(getStringArg(args, "path")))
|
|
case "glob":
|
|
return fmt.Sprintf("glob(%s)", jsQuote(getStringArg(args, "pattern")))
|
|
case "grep_string":
|
|
return fmt.Sprintf("grep_string(%s, %s)", jsQuote(getStringArg(args, "source")), jsQuote(getStringArg(args, "str")))
|
|
case "grep_regex":
|
|
return fmt.Sprintf("grep_regex(%s, %s)", jsQuote(getStringArg(args, "source")), jsQuote(getStringArg(args, "pattern")))
|
|
case "http_get":
|
|
return fmt.Sprintf("http_get(%s)", jsQuote(getStringArg(args, "url")))
|
|
case "http_request":
|
|
return fmt.Sprintf("http_request(%s, %s, %s, %s)",
|
|
jsQuote(getStringArg(args, "url")),
|
|
jsQuote(getStringArg(args, "method")),
|
|
jsQuote(getStringArg(args, "headers")),
|
|
jsQuote(getStringArg(args, "body")),
|
|
)
|
|
case "jq":
|
|
return fmt.Sprintf("jq(%s, %s)", jsQuote(getStringArg(args, "json_data")), jsQuote(getStringArg(args, "expression")))
|
|
case "exec_python":
|
|
return fmt.Sprintf("exec_python(%s)", jsQuote(getStringArg(args, "code")))
|
|
case "exec_python_file":
|
|
return fmt.Sprintf("exec_python_file(%s)", jsQuote(getStringArg(args, "path")))
|
|
case "exec_ts":
|
|
return fmt.Sprintf("exec_ts(%s)", jsQuote(getStringArg(args, "code")))
|
|
case "exec_ts_file":
|
|
return fmt.Sprintf("exec_ts_file(%s)", jsQuote(getStringArg(args, "path")))
|
|
case "run_module":
|
|
return fmt.Sprintf("run_module(%s, %s, %s)", jsQuote(getStringArg(args, "module")), jsQuote(getStringArg(args, "target")), jsQuote(getStringArg(args, "params")))
|
|
case "run_flow":
|
|
return fmt.Sprintf("run_flow(%s, %s, %s)", jsQuote(getStringArg(args, "flow")), jsQuote(getStringArg(args, "target")), jsQuote(getStringArg(args, "params")))
|
|
default:
|
|
var argStrs []string
|
|
for _, v := range args {
|
|
argStrs = append(argStrs, jsQuote(fmt.Sprintf("%v", v)))
|
|
}
|
|
return fmt.Sprintf("%s(%s)", funcName, strings.Join(argStrs, ", "))
|
|
}
|
|
}
|
|
|
|
// getStringArg safely extracts a string argument from the args map
|
|
func getStringArg(args map[string]interface{}, key string) string {
|
|
if v, ok := args[key]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// jsQuote returns a JavaScript string literal, escaping special characters
|
|
func jsQuote(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
|
s = strings.ReplaceAll(s, "\n", `\n`)
|
|
s = strings.ReplaceAll(s, "\r", `\r`)
|
|
s = strings.ReplaceAll(s, "\t", `\t`)
|
|
return `"` + s + `"`
|
|
}
|
|
|
|
// formatToolResult converts a tool execution result to a string for the LLM
|
|
func formatToolResult(result interface{}) string {
|
|
if result == nil {
|
|
return ""
|
|
}
|
|
|
|
switch v := result.(type) {
|
|
case string:
|
|
return v
|
|
case bool:
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case int, int64, float64:
|
|
return fmt.Sprintf("%v", v)
|
|
default:
|
|
jsonBytes, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return string(jsonBytes)
|
|
}
|
|
}
|
|
|
|
// applyMessageWindow trims the conversation to max_messages, keeping the system message
|
|
func (e *AgentExecutor) applyMessageWindow(state *agentState, maxMessages int) {
|
|
if maxMessages <= 0 || len(state.messages) <= maxMessages {
|
|
return
|
|
}
|
|
|
|
var systemMsg *ChatMessage
|
|
startIdx := 0
|
|
if len(state.messages) > 0 && state.messages[0].Role == string(core.LLMRoleSystem) {
|
|
systemMsg = &state.messages[0]
|
|
startIdx = 1
|
|
}
|
|
|
|
nonSystemMsgs := state.messages[startIdx:]
|
|
keepCount := maxMessages
|
|
if systemMsg != nil {
|
|
keepCount--
|
|
}
|
|
|
|
if len(nonSystemMsgs) > keepCount {
|
|
nonSystemMsgs = nonSystemMsgs[len(nonSystemMsgs)-keepCount:]
|
|
}
|
|
|
|
if systemMsg != nil {
|
|
state.messages = make([]ChatMessage, 0, keepCount+1)
|
|
state.messages = append(state.messages, *systemMsg)
|
|
state.messages = append(state.messages, nonSystemMsgs...)
|
|
} else {
|
|
state.messages = nonSystemMsgs
|
|
}
|
|
}
|
|
|
|
// applyMessageWindowWithSummary trims with LLM-based summarization of dropped messages (Group 4)
|
|
func (e *AgentExecutor) applyMessageWindowWithSummary(
|
|
ctx context.Context,
|
|
state *agentState,
|
|
maxMessages int,
|
|
llmConfig *MergedLLMConfig,
|
|
) {
|
|
if maxMessages <= 0 || len(state.messages) <= maxMessages {
|
|
return
|
|
}
|
|
|
|
log := logger.Get()
|
|
|
|
var systemMsg *ChatMessage
|
|
startIdx := 0
|
|
if len(state.messages) > 0 && state.messages[0].Role == string(core.LLMRoleSystem) {
|
|
systemMsg = &state.messages[0]
|
|
startIdx = 1
|
|
}
|
|
|
|
nonSystemMsgs := state.messages[startIdx:]
|
|
keepCount := maxMessages
|
|
if systemMsg != nil {
|
|
keepCount-- // Account for system message
|
|
keepCount-- // Account for summary message we'll insert
|
|
}
|
|
if keepCount < 1 {
|
|
keepCount = 1
|
|
}
|
|
|
|
if len(nonSystemMsgs) <= keepCount {
|
|
return
|
|
}
|
|
|
|
// Messages to be dropped
|
|
dropCount := len(nonSystemMsgs) - keepCount
|
|
droppedMsgs := nonSystemMsgs[:dropCount]
|
|
keptMsgs := nonSystemMsgs[dropCount:]
|
|
|
|
// Build summary of dropped messages
|
|
var summaryParts []string
|
|
for _, msg := range droppedMsgs {
|
|
if content, ok := msg.Content.(string); ok && content != "" {
|
|
role := msg.Role
|
|
// Truncate long messages
|
|
if len(content) > 200 {
|
|
content = content[:200] + "..."
|
|
}
|
|
summaryParts = append(summaryParts, fmt.Sprintf("[%s]: %s", role, content))
|
|
}
|
|
}
|
|
|
|
if len(summaryParts) == 0 {
|
|
// No meaningful content to summarize, just truncate
|
|
e.applyMessageWindow(state, maxMessages)
|
|
return
|
|
}
|
|
|
|
// Ask LLM to summarize the dropped context
|
|
summaryPrompt := "Summarize the following conversation context concisely, preserving key information:\n\n" + strings.Join(summaryParts, "\n")
|
|
summaryMessages := []ChatMessage{
|
|
{Role: string(core.LLMRoleUser), Content: summaryPrompt},
|
|
}
|
|
|
|
summaryState := &agentState{messages: summaryMessages}
|
|
summaryConfig := *llmConfig
|
|
summaryConfig.MaxTokens = 300
|
|
|
|
response, err := e.callLLM(ctx, summaryState, nil, &summaryConfig)
|
|
if err != nil {
|
|
log.Warn("Conversation summarization failed, falling back to simple truncation",
|
|
zap.Error(err),
|
|
)
|
|
e.applyMessageWindow(state, maxMessages)
|
|
return
|
|
}
|
|
|
|
summaryContent := ""
|
|
if response != nil && len(response.Choices) > 0 {
|
|
if content, ok := response.Choices[0].Message.Content.(string); ok {
|
|
summaryContent = content
|
|
}
|
|
// Track summarization tokens
|
|
state.totalTokens += response.Usage.TotalTokens
|
|
state.promptTokens += response.Usage.PromptTokens
|
|
state.completionTokens += response.Usage.CompletionTokens
|
|
}
|
|
|
|
if summaryContent == "" {
|
|
e.applyMessageWindow(state, maxMessages)
|
|
return
|
|
}
|
|
|
|
// Rebuild messages with summary
|
|
state.messages = make([]ChatMessage, 0, keepCount+2)
|
|
if systemMsg != nil {
|
|
state.messages = append(state.messages, *systemMsg)
|
|
}
|
|
state.messages = append(state.messages, ChatMessage{
|
|
Role: string(core.LLMRoleSystem),
|
|
Content: "[Summary of earlier conversation]\n" + summaryContent,
|
|
})
|
|
state.messages = append(state.messages, keptMsgs...)
|
|
}
|
|
|
|
// loadConversation loads a prior conversation from a JSON file
|
|
func (e *AgentExecutor) loadConversation(state *agentState, path string) error {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read conversation file: %w", err)
|
|
}
|
|
|
|
var messages []ChatMessage
|
|
if err := json.Unmarshal(data, &messages); err != nil {
|
|
return fmt.Errorf("failed to parse conversation file: %w", err)
|
|
}
|
|
|
|
state.messages = messages
|
|
return nil
|
|
}
|
|
|
|
// persistConversation saves the conversation to a JSON file
|
|
func (e *AgentExecutor) persistConversation(state *agentState, path string) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(state.messages, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal conversation: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
return fmt.Errorf("failed to write conversation file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getMergedConfig merges global and step-level LLM configuration
|
|
func (e *AgentExecutor) getMergedConfig(step *core.Step) *MergedLLMConfig {
|
|
llmExec := &LLMExecutor{config: e.config}
|
|
return llmExec.getMergedConfig(step)
|
|
}
|
|
|
|
// fail is a helper that sets the result to failed state
|
|
func (e *AgentExecutor) fail(result *core.StepResult, err error) (*core.StepResult, error) {
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|